@remit/web-client 0.0.264 → 0.0.266

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.264",
3
+ "version": "0.0.266",
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": {
@@ -41,17 +41,23 @@ const ComposeContext = createContext<ComposeContextValue | undefined>(
41
41
  const POLL_INTERVAL_MS = 2000;
42
42
  const MAX_POLL_DURATION_MS = 60_000;
43
43
 
44
+ /**
45
+ * `sent` is not one of these. It is the window between SMTP handing the message
46
+ * over and the APPEND either filing it — row deleted, 404 — or settling it
47
+ * `unfiled`, and the outbox list renders no `sent` row (`isOutboxListRow`). A
48
+ * watch that stopped there invalidated the list over a row nothing displays and
49
+ * never asked again, so the `unfiled` a beat later reached no screen: #824's
50
+ * message the sender can reach in no view, arrived at from the client's side.
51
+ * A row stranded at `sent` by a delete that failed polls to the cap below,
52
+ * which is what the cap is for.
53
+ */
44
54
  const isSettledStatus = (status: string | undefined): boolean =>
45
- status === "sent" ||
46
- status === "unfiled" ||
47
- status === "failed" ||
48
- status === "blocked";
55
+ status === "unfiled" || status === "failed" || status === "blocked";
49
56
 
50
57
  /**
51
58
  * A send that lands is filed and then forgotten: the worker APPENDs the message
52
59
  * to Sent and drops the outbox row, so the row's absence is the settled state
53
- * and the 404 is the confirmation. `sent` lives for under a second and this poll
54
- * runs every two, so the 404 is the outcome it normally reads.
60
+ * and the 404 is the confirmation.
55
61
  *
56
62
  * Only the 404 is this call site's to own. A 401 or a 403 here is a session that
57
63
  * lapsed under a send, which the app owes the user an answer about.
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Issue #1040: the send watch counted `sent` as its answer and came down on it.
3
+ *
4
+ * `sent` is the window between SMTP handing the message over and the APPEND
5
+ * either filing it — row deleted, 404 — or settling it `unfiled`. It is a few
6
+ * tens of milliseconds wide and the watch's first read lands inside it perhaps
7
+ * one send in twenty, which is where the e2e flake came from. What the user got
8
+ * when it did land there is worse than a flake: the watch stopped, invalidated
9
+ * the outbox list over a row the list does not render, and never asked again —
10
+ * so a message that went out unfiled was on no screen at all, which is the very
11
+ * state #824 exists to prevent.
12
+ */
13
+
14
+ import assert from "node:assert/strict";
15
+ import { afterEach, describe, it } from "node:test";
16
+ import { outboxOperationsListOutboxMessagesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
17
+ import { createElement, useEffect } from "react";
18
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
19
+ import { type HttpMock, mockFetch } from "../../test-support/http";
20
+ import { ComposeProvider, useCompose } from "./ComposeProvider";
21
+
22
+ const OUTBOX_MESSAGE_ID = "ob-1040";
23
+ const POLL_INTERVAL_MS = 2000;
24
+
25
+ const UNFILED_REASON =
26
+ "Sent, but not filed: this account has no folder appointed to the Sent role.";
27
+
28
+ let harness: DomHarness | undefined;
29
+ let http: HttpMock | undefined;
30
+ let status = "sent";
31
+
32
+ afterEach(() => {
33
+ harness?.close();
34
+ harness = undefined;
35
+ http?.restore();
36
+ http = undefined;
37
+ status = "sent";
38
+ });
39
+
40
+ const polls = (): number => (http?.to(OUTBOX_MESSAGE_ID) ?? []).length;
41
+
42
+ const outboxListIsStale = (): boolean =>
43
+ harness?.queryClient
44
+ .getQueryCache()
45
+ .find({ queryKey: outboxOperationsListOutboxMessagesQueryKey() })?.state
46
+ .isInvalidated === true;
47
+
48
+ const Sender = () => {
49
+ const { startSendPolling } = useCompose();
50
+
51
+ useEffect(() => {
52
+ startSendPolling(OUTBOX_MESSAGE_ID);
53
+ }, [startSendPolling]);
54
+
55
+ return null;
56
+ };
57
+
58
+ const mount = async (): Promise<void> => {
59
+ http = mockFetch((call) => {
60
+ if (call.path.endsWith(OUTBOX_MESSAGE_ID)) {
61
+ return {
62
+ outboxMessageId: OUTBOX_MESSAGE_ID,
63
+ status,
64
+ lastError: status === "unfiled" ? UNFILED_REASON : undefined,
65
+ };
66
+ }
67
+ return { messages: [] };
68
+ });
69
+
70
+ harness = createDomHarness();
71
+ harness.queryClient.setQueryData(
72
+ outboxOperationsListOutboxMessagesQueryKey(),
73
+ { messages: [] },
74
+ );
75
+ harness.renderApp(
76
+ createElement(ComposeProvider, null, createElement(Sender)),
77
+ );
78
+ await harness.flush();
79
+ };
80
+
81
+ const reachedPolls = async (count: number): Promise<void> => {
82
+ await harness?.waitFor(
83
+ () => polls() >= count,
84
+ `the watch to have read the row ${count} times`,
85
+ POLL_INTERVAL_MS * count + POLL_INTERVAL_MS,
86
+ );
87
+ };
88
+
89
+ describe("a send whose filing is still owed (#1040)", () => {
90
+ it("keeps asking while the row reads sent", async () => {
91
+ await mount();
92
+ await reachedPolls(1);
93
+
94
+ assert.ok(
95
+ !outboxListIsStale(),
96
+ "nothing was settled, so nothing refetched",
97
+ );
98
+
99
+ await reachedPolls(3);
100
+ assert.ok(
101
+ !outboxListIsStale(),
102
+ "`sent` is not an answer the watch stops on",
103
+ );
104
+ });
105
+
106
+ it("stops on the unfiled row, and only then refetches the list", async () => {
107
+ await mount();
108
+ await reachedPolls(2);
109
+
110
+ status = "unfiled";
111
+ await harness?.waitFor(
112
+ () => outboxListIsStale(),
113
+ "the watch to settle on the unfiled row and invalidate the list",
114
+ POLL_INTERVAL_MS * 3,
115
+ );
116
+
117
+ const settled = polls();
118
+ await harness?.wait(POLL_INTERVAL_MS * 2);
119
+ assert.equal(polls(), settled, "the settled row ended the watch");
120
+ });
121
+ });
@@ -1,10 +1,13 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
+ CATEGORY_PRESENTATION,
4
+ categoryLabels,
3
5
  DialogBackdrop,
4
6
  IntelligencePanel,
5
7
  type IntelligenceQuickActions,
6
8
  type SimilarMessageLinkComponent,
7
9
  type SimilarState,
10
+ type ThreadCategory,
8
11
  } from "@remit/ui";
9
12
  import { Sparkles } from "lucide-react";
10
13
  import { useCallback, useState } from "react";
@@ -52,18 +55,20 @@ export interface IntelligencePaneProps {
52
55
 
53
56
  /**
54
57
  * Category-override values accepted by the `AddressFlags.category` override
55
- * (PATCH /addresses/{id}). Matches `MessageCategory` — the full taxonomy the
56
- * user can assign as a sender-level override.
58
+ * (PATCH /addresses/{id}), in the kit's chip-row order and named with the kit's
59
+ * words. `uncategorized` is left out: it names the classifier not having reached
60
+ * a message yet, which is not something a reader chooses for a sender.
57
61
  */
58
- const CATEGORY_OVERRIDES = [
59
- "personal",
60
- "newsletter",
61
- "marketing",
62
- "automated",
63
- "transactional",
64
- "social",
65
- ] as const;
66
- type CategoryOverride = (typeof CATEGORY_OVERRIDES)[number];
62
+ type CategoryOverride = Exclude<ThreadCategory, "uncategorized">;
63
+
64
+ const isCategoryOverride = (
65
+ category: ThreadCategory,
66
+ ): category is CategoryOverride => category !== "uncategorized";
67
+
68
+ const CATEGORY_OVERRIDES: readonly CategoryOverride[] =
69
+ CATEGORY_PRESENTATION.map((entry) => entry.category).filter(
70
+ isCategoryOverride,
71
+ );
67
72
 
68
73
  /**
69
74
  * Decide which spam quick-action to offer for the current message (issue #648).
@@ -189,11 +194,11 @@ function ReclassifyDialog({
189
194
  key={cat}
190
195
  type="button"
191
196
  onClick={() => onSelect(cat)}
192
- className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm capitalize transition-colors hover:bg-surface-raised ${
197
+ className={`flex min-h-11 items-center justify-between rounded px-3 text-left text-sm transition-colors hover:bg-surface-raised ${
193
198
  cat === current ? "font-semibold text-accent" : "text-fg"
194
199
  }`}
195
200
  >
196
- {cat}
201
+ {categoryLabels[cat]}
197
202
  {cat === current && (
198
203
  <span className="text-2xs text-fg-subtle">current</span>
199
204
  )}
@@ -605,14 +605,10 @@ describe("the brief's sections come from the server (#312)", () => {
605
605
  shown.indexOf("Weekly digest for you"),
606
606
  "an old newsletter still outranked a newer match",
607
607
  );
608
- for (const label of ["Newsletter", "Automated", "Personal"]) {
609
- assert.ok(
610
- !shown.includes(label),
611
- `the search kept the ${label} section header`,
612
- );
613
- }
608
+ // A category's word still reaches the screen on a row badge, so the
609
+ // headers are counted rather than read off the text.
614
610
  assert.equal(
615
- mounted.queryAll('button[aria-expanded="true"]').length,
611
+ mounted.queryAll("[data-section-header]").length,
616
612
  0,
617
613
  "the search rendered a section header",
618
614
  );
@@ -1,8 +1,14 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import type { ResultCount, ThreadCategory, ThreadRowData } from "@remit/ui";
5
4
  import {
5
+ categoryLabels,
6
+ type ResultCount,
7
+ type ThreadCategory,
8
+ type ThreadRowData,
9
+ } from "@remit/ui";
10
+ import {
11
+ BRIEF_CATEGORIES,
6
12
  type BriefCategoryResult,
7
13
  briefSections,
8
14
  briefSectionTotal,
@@ -109,16 +115,8 @@ describe("briefSections", () => {
109
115
  });
110
116
 
111
117
  test("each category answers for its own section, with its own label", () => {
112
- const labels = new Map<ThreadCategory, string>([
113
- ["personal", "Personal"],
114
- ["transactional", "Transactional"],
115
- ["newsletter", "Newsletter"],
116
- ["marketing", "Marketing"],
117
- ["social", "Social"],
118
- ["automated", "Automated"],
119
- ["uncategorized", "Unclassified"],
120
- ]);
121
- for (const [category, label] of labels) {
118
+ for (const category of BRIEF_CATEGORIES) {
119
+ const label = categoryLabels[category];
122
120
  const sections = briefSections([
123
121
  result(category, [row({ id: "1", category })]),
124
122
  ]);
package/src/lib/brief.ts CHANGED
@@ -36,13 +36,14 @@
36
36
  */
37
37
 
38
38
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
39
- import { MessageCategory } from "@remit/domain-enums";
40
- import type {
41
- ResultCount,
42
- SenderTrustLevel,
43
- ThreadCategory,
44
- ThreadRowData,
45
- ThreadSection,
39
+ import {
40
+ CATEGORY_SECTION_ORDER,
41
+ categoryLabels,
42
+ type ResultCount,
43
+ type SenderTrustLevel,
44
+ type ThreadCategory,
45
+ type ThreadRowData,
46
+ type ThreadSection,
46
47
  } from "@remit/ui";
47
48
  import { toDisplayCategory } from "./display-category.js";
48
49
  import { formatEmailDate } from "./format.js";
@@ -145,39 +146,12 @@ export function mergeSearchRows(
145
146
  }
146
147
 
147
148
  /**
148
- * Category sections in fixed display order. The `id`/`label` drive the rendered
149
- * section; `category` is both the section's own scope and the query parameter
150
- * the section's request carries.
149
+ * The categories the brief asks for, in the order it renders them. A category
150
+ * is both the section's own scope and the query parameter its request carries;
151
+ * the order and the label come from the kit's category table.
151
152
  */
152
- export const CATEGORY_SECTIONS: ReadonlyArray<{
153
- id: string;
154
- label: string;
155
- category: ThreadCategory;
156
- }> = [
157
- { id: "personal", label: "Personal", category: MessageCategory.personal },
158
- {
159
- id: "transactional",
160
- label: "Transactional",
161
- category: MessageCategory.transactional,
162
- },
163
- {
164
- id: "newsletter",
165
- label: "Newsletter",
166
- category: MessageCategory.newsletter,
167
- },
168
- { id: "marketing", label: "Marketing", category: MessageCategory.marketing },
169
- { id: "social", label: "Social", category: MessageCategory.social },
170
- { id: "automated", label: "Automated", category: MessageCategory.automated },
171
- {
172
- id: "uncategorized",
173
- label: "Unclassified",
174
- category: MessageCategory.uncategorized,
175
- },
176
- ];
177
-
178
- /** The categories the brief asks for, in the order it renders them. */
179
153
  export const BRIEF_CATEGORIES: readonly ThreadCategory[] =
180
- CATEGORY_SECTIONS.map((section) => section.category);
154
+ CATEGORY_SECTION_ORDER;
181
155
 
182
156
  /** One category's own server answer: its newest rows and how many it holds. */
183
157
  export interface BriefCategoryResult {
@@ -214,8 +188,8 @@ export function briefSections(
214
188
  results.map((result) => [result.category, result] as const),
215
189
  );
216
190
  const sections: ThreadSection[] = [];
217
- for (const section of CATEGORY_SECTIONS) {
218
- const result = byCategory.get(section.category);
191
+ for (const category of BRIEF_CATEGORIES) {
192
+ const result = byCategory.get(category);
219
193
  if (!result) continue;
220
194
  const holdsMail =
221
195
  result.loading ||
@@ -224,8 +198,8 @@ export function briefSections(
224
198
  (result.total.kind === "exact" && result.total.value > 0);
225
199
  if (!holdsMail) continue;
226
200
  sections.push({
227
- id: section.id,
228
- label: section.label,
201
+ id: category,
202
+ label: categoryLabels[category],
229
203
  threads: result.rows,
230
204
  total: result.total,
231
205
  atCap: result.atCap,
@@ -3,7 +3,7 @@ import type {
3
3
  ThreadOperationsSearchThreadsData,
4
4
  } from "@remit/api-http-client/types.gen.ts";
5
5
  import { MessageCategory } from "@remit/domain-enums";
6
- import { type FilterReach, inboxFilterConfig } from "@remit/ui";
6
+ import { categoryLabels, type FilterReach, isThreadCategory } from "@remit/ui";
7
7
 
8
8
  /** The request `threadOperationsSearchThreads` takes, whole. */
9
9
  export type ThreadSearchQuery = NonNullable<
@@ -131,14 +131,7 @@ export const sameInboxFilter = (
131
131
  /**
132
132
  * The label the empty state names an active category filter by, or undefined
133
133
  * when the id names no category — `"all"` is how the category is cleared, not a
134
- * category. Read off the preset so the chip and the sentence about it can never
135
- * disagree; every list preset draws its categories from the same set.
134
+ * category.
136
135
  */
137
- const CATEGORY_LABELS = new Map(
138
- inboxFilterConfig()
139
- .categories.filter((category) => category.id !== "all")
140
- .map((category) => [category.id, category.label]),
141
- );
142
-
143
136
  export const categoryLabel = (id: string): string | undefined =>
144
- CATEGORY_LABELS.get(id);
137
+ isThreadCategory(id) ? categoryLabels[id] : undefined;
@@ -43,6 +43,7 @@
43
43
  import type { RemitImapMessageCategory } from "@remit/api-http-client/types.gen.ts";
44
44
  import { MessageCategory } from "@remit/domain-enums";
45
45
  import {
46
+ categoryLabels,
46
47
  quoteSearchTokenValue,
47
48
  type SearchQueryWord,
48
49
  type SearchTermParts,
@@ -134,24 +135,9 @@ export interface SearchTokenSpec {
134
135
  options?: readonly SearchTokenValueOption[];
135
136
  }
136
137
 
137
- /**
138
- * Category labels, keyed by the API enum. `uncategorized` reads "Unclassified"
139
- * — the same word the filter chips use (issue #45): mail the classifier has not
140
- * reached is a pending state with a name, never the absence of one.
141
- */
142
- const CATEGORY_LABELS: Record<RemitImapMessageCategory, string> = {
143
- personal: "Personal",
144
- uncategorized: "Unclassified",
145
- transactional: "Transactional",
146
- newsletter: "Newsletter",
147
- marketing: "Marketing",
148
- social: "Social",
149
- automated: "Automated",
150
- };
151
-
152
138
  const CATEGORY_OPTIONS: readonly SearchTokenValueOption[] = Object.values(
153
139
  MessageCategory,
154
- ).map((value) => ({ value, label: CATEGORY_LABELS[value] }));
140
+ ).map((value) => ({ value, label: categoryLabels[value] }));
155
141
 
156
142
  /**
157
143
  * Spellings that resolve to a category beyond the enum values themselves — the
@@ -313,7 +299,7 @@ export function searchTokenLabel(token: SearchToken): string {
313
299
  case "subject":
314
300
  return `Subject: ${token.value}`;
315
301
  case "category":
316
- return `Category: ${CATEGORY_LABELS[token.category]}`;
302
+ return `Category: ${categoryLabels[token.category]}`;
317
303
  case "hasAttachment":
318
304
  return "Has attachment";
319
305
  case "isUnread":