@remit/web-client 0.0.150 → 0.0.152

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.
Files changed (34) hide show
  1. package/package.json +1 -1
  2. package/src/components/compose/ComposeProvider.tsx +27 -5
  3. package/src/components/layout/ComposeFab.tsx +3 -2
  4. package/src/components/mail/BriefPane.tsx +8 -8
  5. package/src/components/mail/DailyBrief.tsx +3 -7
  6. package/src/components/mail/DraftsView.tsx +7 -3
  7. package/src/components/mail/IntelligencePane.tsx +4 -3
  8. package/src/components/mail/MailListHeader.tsx +1 -5
  9. package/src/components/mail/MailSidebarAdapter.tsx +3 -2
  10. package/src/components/mail/MailboxPane.tsx +130 -150
  11. package/src/components/mail/MessageActionMenu.tsx +12 -8
  12. package/src/components/mail/MessageList.selection.test.ts +6 -7
  13. package/src/components/mail/MessageList.tsx +28 -32
  14. package/src/components/mail/MessageListItem.tsx +2 -1
  15. package/src/components/mail/MessageRow.tsx +13 -10
  16. package/src/components/mail/SwipeableMessageRow.modifier-select.test.ts +8 -3
  17. package/src/components/mail/SwipeableMessageRow.tsx +8 -12
  18. package/src/hooks/useIntelligenceData.ts +1 -0
  19. package/src/hooks/useLayoutTier.ts +8 -5
  20. package/src/hooks/useMediaQuery.ts +2 -29
  21. package/src/lib/conversation-target.ts +7 -49
  22. package/src/lib/mail-search.ts +7 -8
  23. package/src/routeTree.gen.ts +74 -0
  24. package/src/routes/mail/$mailboxId/$threadId/$messageId.tsx +19 -0
  25. package/src/routes/mail/$mailboxId/$threadId/index.tsx +14 -0
  26. package/src/routes/mail/$mailboxId/$threadId.tsx +12 -0
  27. package/src/routes/mail/$mailboxId.tsx +7 -3
  28. package/src/routes/mail/brief.tsx +2 -2
  29. package/src/routing/index.ts +5 -5
  30. package/src/routing/open-thread.ts +58 -0
  31. package/src/lib/conversation-target.test.ts +0 -68
  32. package/src/lib/search-pending.test.ts +0 -99
  33. package/src/lib/search-pending.ts +0 -49
  34. package/src/routing/brief-thread.ts +0 -47
@@ -0,0 +1,58 @@
1
+ import { useParams } from "@tanstack/react-router";
2
+ import { useMemo } from "react";
3
+
4
+ /** The conversation a list has open, as the address states it. */
5
+ export interface OpenThreadPath {
6
+ threadId: string;
7
+ /**
8
+ * Which message inside the thread is expanded and scrolled to. Absent on a
9
+ * bare thread address, where the newest message answers for the conversation.
10
+ */
11
+ messageId: string | undefined;
12
+ }
13
+
14
+ /**
15
+ * A conversation to open. The thread is what the pane fetches by; the message is
16
+ * the row the reader pointed at, so a list always knows both.
17
+ */
18
+ export interface OpenThreadTarget {
19
+ threadId: string;
20
+ messageId: string;
21
+ }
22
+
23
+ /**
24
+ * The thread the address has open, whichever list is browsing it.
25
+ *
26
+ * The thread is a child route of the list, and the list layout mounts the pane
27
+ * above the `Outlet` — so it asks the router which of its children matched
28
+ * rather than reading a param it does not own. A path matches one list at a
29
+ * time, so one hook answers for every list rather than each list keeping its
30
+ * own copy of the same question. Each `from` names a real route, so a segment
31
+ * that does not exist fails to compile.
32
+ */
33
+ export function useOpenThreadPath(): OpenThreadPath | undefined {
34
+ const briefThread = useParams({
35
+ from: "/mail/brief/$threadId",
36
+ shouldThrow: false,
37
+ });
38
+ const briefMessage = useParams({
39
+ from: "/mail/brief/$threadId/$messageId",
40
+ shouldThrow: false,
41
+ });
42
+ const mailboxThread = useParams({
43
+ from: "/mail/$mailboxId/$threadId",
44
+ shouldThrow: false,
45
+ });
46
+ const mailboxMessage = useParams({
47
+ from: "/mail/$mailboxId/$threadId/$messageId",
48
+ shouldThrow: false,
49
+ });
50
+
51
+ const threadId = briefThread?.threadId ?? mailboxThread?.threadId;
52
+ const messageId = briefMessage?.messageId ?? mailboxMessage?.messageId;
53
+
54
+ return useMemo(
55
+ () => (threadId ? { threadId, messageId } : undefined),
56
+ [threadId, messageId],
57
+ );
58
+ }
@@ -1,68 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import { describe, test } from "node:test";
3
- import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { buildConversationTarget } from "./conversation-target.js";
5
-
6
- function thread(
7
- overrides: Partial<RemitImapThreadMessageResponse> = {},
8
- ): RemitImapThreadMessageResponse {
9
- return {
10
- messageId: "m1",
11
- threadId: "t1",
12
- mailboxId: "mb1",
13
- subject: "Loaded subject",
14
- isRead: false,
15
- ...overrides,
16
- } as RemitImapThreadMessageResponse;
17
- }
18
-
19
- describe("buildConversationTarget", () => {
20
- test("prefers the loaded thread, carrying its display fields", () => {
21
- const authenticity = { dkimMismatch: true, fromDomain: "example.com" };
22
- const target = buildConversationTarget(thread({ authenticity }), {
23
- messageId: "m1",
24
- threadId: "ignored",
25
- mailboxId: "ignored",
26
- });
27
- assert.deepEqual(target, {
28
- threadId: "t1",
29
- mailboxId: "mb1",
30
- subject: "Loaded subject",
31
- messageId: "m1",
32
- authenticity,
33
- });
34
- });
35
-
36
- // The bug: a tapped semantic "Related" hit's message isn't in the loaded list,
37
- // so there is no thread to resolve — it must still open from the URL hints.
38
- test("falls back to the URL thread + mailbox when no thread is loaded", () => {
39
- const target = buildConversationTarget(undefined, {
40
- messageId: "m9",
41
- threadId: "t9",
42
- mailboxId: "mb9",
43
- });
44
- assert.deepEqual(target, {
45
- threadId: "t9",
46
- mailboxId: "mb9",
47
- messageId: "m9",
48
- });
49
- });
50
-
51
- test("returns undefined when the message is cleared (Back pressed)", () => {
52
- const target = buildConversationTarget(undefined, {
53
- messageId: undefined,
54
- threadId: "t9",
55
- mailboxId: "mb9",
56
- });
57
- assert.equal(target, undefined);
58
- });
59
-
60
- test("returns undefined without a threadId to open", () => {
61
- const target = buildConversationTarget(undefined, {
62
- messageId: "m9",
63
- threadId: undefined,
64
- mailboxId: "mb9",
65
- });
66
- assert.equal(target, undefined);
67
- });
68
- });
@@ -1,99 +0,0 @@
1
- // Regression test for #623: search results must be openable once the query
2
- // settles. The route computes `isSearchPending = searchInput !== searchQuery`
3
- // to decide whether to suppress the reading pane, then resolves the selected
4
- // thread via `resolveSelectedThread`.
5
- //
6
- // - `searchInput` is the live (pre-debounce) value the user is typing.
7
- // - `searchQuery` is the debounced (committed) value sent to the API.
8
- // - The pane is suppressed only while the two differ (a debounce is in flight).
9
- // - Once settled, a selected result should be shown in the reading pane.
10
-
11
- import assert from "node:assert";
12
- import { describe, test } from "node:test";
13
- import {
14
- isSearchPending,
15
- resolveOpenThread,
16
- resolveSelectedThread,
17
- } from "./search-pending.ts";
18
-
19
- describe("isSearchPending (reading-pane suppression guard)", () => {
20
- test("no search: both empty — not pending", () => {
21
- assert.equal(isSearchPending("", ""), false);
22
- });
23
-
24
- test("settled search: input matches debounced query — not pending", () => {
25
- assert.equal(isSearchPending("amazon", "amazon"), false);
26
- });
27
-
28
- test("user starts typing: input differs from query — pending", () => {
29
- assert.equal(isSearchPending("ama", ""), true);
30
- });
31
-
32
- test("mid-debounce: partial input vs previous query — pending", () => {
33
- assert.equal(isSearchPending("amaz", "amazon"), true);
34
- });
35
- });
36
-
37
- describe("resolveSelectedThread (#623 reading-pane resolution)", () => {
38
- const threads = [{ messageId: "msg-001" }, { messageId: "msg-002" }];
39
-
40
- test("regression #623: settled query with a matching selection opens the thread", () => {
41
- const pending = isSearchPending("amazon", "amazon");
42
- assert.equal(pending, false);
43
-
44
- const selectedThread = resolveSelectedThread(threads, "msg-001", pending);
45
- assert.deepEqual(selectedThread, { messageId: "msg-001" });
46
- });
47
-
48
- test("pending search suppresses the reading pane even with a matching selection", () => {
49
- const pending = isSearchPending("amaz", "amazon");
50
- assert.equal(pending, true);
51
-
52
- const selectedThread = resolveSelectedThread(threads, "msg-001", pending);
53
- assert.equal(selectedThread, undefined);
54
- });
55
-
56
- test("settled query but selection absent from results — no thread", () => {
57
- const selectedThread = resolveSelectedThread(threads, "msg-999", false);
58
- assert.equal(selectedThread, undefined);
59
- });
60
-
61
- test("settled query with no selection — no thread", () => {
62
- const selectedThread = resolveSelectedThread(threads, undefined, false);
63
- assert.equal(selectedThread, undefined);
64
- });
65
- });
66
-
67
- describe("resolveOpenThread (#306 filtered list)", () => {
68
- const listed = { messageId: "msg-001", subject: "listed" };
69
- const snapshot = { messageId: "msg-001", subject: "snapshot" };
70
-
71
- test("prefers the row the list returned", () => {
72
- assert.equal(resolveOpenThread(listed, snapshot, "msg-001", false), listed);
73
- });
74
-
75
- test("keeps the open thread when a filter pages it out of the list", () => {
76
- assert.equal(
77
- resolveOpenThread(undefined, snapshot, "msg-001", false),
78
- snapshot,
79
- );
80
- });
81
-
82
- test("drops a snapshot of a message that is no longer selected", () => {
83
- assert.equal(
84
- resolveOpenThread(undefined, snapshot, "msg-002", false),
85
- undefined,
86
- );
87
- assert.equal(
88
- resolveOpenThread(undefined, snapshot, undefined, false),
89
- undefined,
90
- );
91
- });
92
-
93
- test("a pending search still closes the pane (#539)", () => {
94
- assert.equal(
95
- resolveOpenThread(undefined, snapshot, "msg-001", true),
96
- undefined,
97
- );
98
- });
99
- });
@@ -1,49 +0,0 @@
1
- // Reading-pane suppression guard for the mail search bar (#539, #623).
2
- //
3
- // `searchInput` is the live (pre-debounce) value the user is typing;
4
- // `searchQuery` is the debounced (committed) value sent to the API. A debounce
5
- // is in flight whenever the two differ — during that window the reading pane is
6
- // kept closed so it clears the instant a new search starts (#539). Once the
7
- // query settles, a selected result is honored so search results can be opened
8
- // (#623).
9
- export const isSearchPending = (
10
- searchInput: string,
11
- searchQuery: string,
12
- ): boolean => searchInput !== searchQuery;
13
-
14
- // Resolve the thread to show in the reading pane. Returns `undefined` while a
15
- // search debounce is pending or when nothing is selected; otherwise looks the
16
- // selected message up in the loaded thread list.
17
- export const resolveSelectedThread = <T extends { messageId: string }>(
18
- threads: T[],
19
- selectedMessageId: string | undefined,
20
- pending: boolean,
21
- ): T | undefined => {
22
- if (pending || !selectedMessageId) return undefined;
23
- return threads.find((t) => t.messageId === selectedMessageId);
24
- };
25
-
26
- /**
27
- * The thread the reading pane shows, once the list is a server-side query
28
- * (#306).
29
- *
30
- * The list used to hold every loaded row and filter a copy, so an open thread
31
- * could always be found again in the unfiltered set. A filtered list is now the
32
- * server's answer to one predicate, and a chip the open message does not match
33
- * pages it out — so what the user opened is kept as a snapshot and answers for
34
- * itself until they open something else. That is a derivation over the user's
35
- * own selection, which stays on this side of the boundary.
36
- *
37
- * The snapshot is ignored while a search debounce is pending, so the pane still
38
- * clears the instant a new search starts (#539).
39
- */
40
- export const resolveOpenThread = <T extends { messageId: string }>(
41
- listed: T | undefined,
42
- opened: T | undefined,
43
- selectedMessageId: string | undefined,
44
- pending: boolean,
45
- ): T | undefined => {
46
- if (listed) return listed;
47
- if (pending || !selectedMessageId) return undefined;
48
- return opened?.messageId === selectedMessageId ? opened : undefined;
49
- };
@@ -1,47 +0,0 @@
1
- import { useParams } from "@tanstack/react-router";
2
- import { useMemo } from "react";
3
-
4
- /** The conversation the brief has open, as the address states it. */
5
- export interface BriefThreadPath {
6
- threadId: string;
7
- /**
8
- * Which message inside the thread is expanded and scrolled to. Absent on a
9
- * bare thread address, where the newest message answers for the conversation.
10
- */
11
- messageId: string | undefined;
12
- }
13
-
14
- /**
15
- * A conversation to open. The thread is what the pane fetches by; the message is
16
- * the row the reader pointed at, so a list always knows both.
17
- */
18
- export interface BriefThreadTarget {
19
- threadId: string;
20
- messageId: string;
21
- }
22
-
23
- /**
24
- * The thread the brief has open, read off the path.
25
- *
26
- * The thread is a child route of the list, and the list layout mounts the pane
27
- * above the `Outlet` — so it asks the router which of its children matched
28
- * rather than reading a param it does not own. Each `from` names a real route,
29
- * so a segment that does not exist fails to compile.
30
- */
31
- export function useBriefThreadPath(): BriefThreadPath | undefined {
32
- const thread = useParams({
33
- from: "/mail/brief/$threadId",
34
- shouldThrow: false,
35
- });
36
- const message = useParams({
37
- from: "/mail/brief/$threadId/$messageId",
38
- shouldThrow: false,
39
- });
40
- const threadId = thread?.threadId;
41
- const messageId = message?.messageId;
42
-
43
- return useMemo(
44
- () => (threadId ? { threadId, messageId } : undefined),
45
- [threadId, messageId],
46
- );
47
- }