@remit/web-client 0.0.128 → 0.0.130

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.
@@ -0,0 +1,244 @@
1
+ import {
2
+ mailboxOperationsListMailboxesQueryKey,
3
+ threadDetailOperationsListThreadMessagesQueryKey,
4
+ } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
5
+ import {
6
+ messageBulkOperationsNotSpam,
7
+ messageBulkOperationsReportSpam,
8
+ } from "@remit/api-http-client/sdk.gen.ts";
9
+ import type { RemitImapSpamReportBulkResult } from "@remit/api-http-client/types.gen.ts";
10
+ import { useMutation, useQueryClient } from "@tanstack/react-query";
11
+ import { useCallback } from "react";
12
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
13
+ import { formatErrorDetail } from "@/components/ui/error-banners";
14
+ import { ApiError } from "@/lib/api";
15
+ import { runChunkedMutation } from "@/lib/bulk-actions";
16
+ import {
17
+ invalidateThreadListQueries,
18
+ threadListCacheKeys,
19
+ } from "@/lib/thread-list-cache";
20
+
21
+ interface UseReportSpamOptions {
22
+ /** The mailbox the message currently sits in — scopes which list caches settle-time invalidation reaches. */
23
+ mailboxId: string;
24
+ threadId?: string;
25
+ accountId?: string;
26
+ /**
27
+ * Called once a report or undo succeeds, with the message ids it acted on
28
+ * — the same shape as `useMoveMessages`/`useDeleteMessages`'s option of the
29
+ * same name, so a host wires the identical `handleDeselectIfRemoved` it
30
+ * already has. Fires on SUCCESS here, not optimistically like those two:
31
+ * neither endpoint tells the client whether the message actually left
32
+ * `mailboxId` (reporting or undoing a message already in Junk is a real
33
+ * no-op-move, and the client has no way to predict it — see
34
+ * `throwOnBulkFailure`'s neighbour below), so this fires for every success,
35
+ * no-op included — a no-op report/undo still deselects even though the row
36
+ * never actually left the list the host is watching. That trades an
37
+ * occasional unnecessary pane-close for never leaving a reported message
38
+ * rendering a pre-report snapshot forever, which is the bug this exists to
39
+ * fix (issue #648 review).
40
+ */
41
+ onAfterOptimisticRemove?: (messageIds: string[]) => void;
42
+ }
43
+
44
+ /**
45
+ * The one designed, allowlisted reason returned by the server as-is (e.g.
46
+ * `notSpam`'s move-not-settled-yet message) is safe to show verbatim; an
47
+ * unexpected failure is already flattened server-side to this same generic
48
+ * text (`GENERIC_FAILURE_REASON` in `packages/backend/src/handlers/message.ts`)
49
+ * before it reaches the client. Used only as a fallback for the case neither
50
+ * hits: a `failures` entry missing its `reason`, or no `failures` array at all
51
+ * despite a non-zero `failureCount`.
52
+ */
53
+ export const GENERIC_SPAM_ACTION_FAILURE =
54
+ "This message could not be processed. Please try again.";
55
+
56
+ /**
57
+ * The server's designed failure text names the message by embedding its raw
58
+ * id as a possessive subject — e.g. "Message 4kv0xxyfhg4dhzqvxd105v840's move
59
+ * to Junk has not settled yet; try again in a moment." (message ids here are
60
+ * 25-char base36, `translator.generate()` in `packages/data-ports/src/id.ts`
61
+ * — not a dashed UUID). Accurate, but not something to put in front of a
62
+ * person. `messageId` is the exact id the failure names — `SpamReportFailure`
63
+ * pairs it with `reason` for precisely this — so this strips that literal
64
+ * substring rather than pattern-matching an id shape that could change under
65
+ * it. Not a parse of the reason's meaning (the field is documented "not
66
+ * intended to be parsed programmatically"): removing a known, opaque
67
+ * identifier from where it renders reads nothing into what the sentence says.
68
+ */
69
+ export const humanizeSpamFailureReason = (
70
+ reason: string,
71
+ messageId: string,
72
+ ): string => reason.replace(`Message ${messageId}'s`, "This message's");
73
+
74
+ /**
75
+ * `settleSpamReportBulk` (backend) runs the batch with `Promise.allSettled`
76
+ * and always answers 200, folding per-message outcomes into
77
+ * `successCount`/`failureCount`/`failures` rather than rejecting the HTTP
78
+ * call — a partial (or total) failure is not a thrown error on the wire. The
79
+ * generated mutation helpers only ever reject on a non-2xx response, so
80
+ * without this the hook would report success for a message whose report or
81
+ * undo never actually happened. Every caller here sends exactly one message
82
+ * per call, so surfacing the first failure's reason is surfacing the whole
83
+ * story, not truncating a real batch.
84
+ *
85
+ * Throws `ApiError` — not a bare `Error` — carrying a 4xx status. The
86
+ * client's fail-fast contract (`lib/error-classifier.ts`'s `shouldEscalate`,
87
+ * wired globally on the `MutationCache` in `lib/query-error-handler.ts`)
88
+ * escalates anything that isn't a 5xx by default UNLESS the call site opts
89
+ * out via `meta.softError` — a statusless `Error` escalates too, as a client
90
+ * bug. Either way, without both the status AND `meta.softError` (set on the
91
+ * `useMutation` calls below) this would crash to the full-screen fatal
92
+ * overlay for a designed, expected, retryable outcome the backend wrote
93
+ * user-facing copy for. Every other call site in this app forwards a
94
+ * status-carrying error the generated client already threw and never needs
95
+ * the opt-out because none of them are a routine, expected-failure signal
96
+ * like this one; this is the one place synthesizing a failure from a 200
97
+ * body, so it has to synthesize both.
98
+ */
99
+ export const throwOnBulkFailure = (
100
+ data: RemitImapSpamReportBulkResult,
101
+ ): void => {
102
+ if (data.failureCount === 0) return;
103
+ const failure = data.failures?.[0];
104
+ const reason = failure?.reason ?? GENERIC_SPAM_ACTION_FAILURE;
105
+ const message = failure
106
+ ? humanizeSpamFailureReason(reason, failure.messageId)
107
+ : reason;
108
+ throw new ApiError(message, 422);
109
+ };
110
+
111
+ /**
112
+ * Composes `POST /messages/report-spam` and `POST /messages/not-spam`
113
+ * (issue #648). Both fold the sender-flag write, the Junk move and the
114
+ * `$Junk` keyword marker into one server-side operation
115
+ * (`SpamReportService`) — the client only sends `messageIds` and reflects the
116
+ * result, it does not sequence the three writes itself, and it never derives
117
+ * "reported" from placement (a report on a message already in Junk — the
118
+ * provider's own filter put it there — is a real, no-op-move case).
119
+ *
120
+ * No optimistic cache patch, unlike `useMoveMessages`/`useDeleteMessages`:
121
+ * neither endpoint's response says whether the message actually changed
122
+ * mailboxes (a report or undo against a message already in Junk is a real,
123
+ * silent no-op), so predicting the row should vanish would flicker it back
124
+ * on invalidation for a no-op — worst on `notSpam`, whose R2 wait can run
125
+ * several seconds before the row "pops back". The list settles from
126
+ * `onSuccess`'s invalidation instead, which is always correct. `isReporting`/
127
+ * `isRestoring` below exist to pay for the UX this trades away: without them
128
+ * a press produces no visible change at all until the request lands — the
129
+ * caller wires them into the quick action's pending state.
130
+ */
131
+ export function useReportSpam({
132
+ mailboxId,
133
+ threadId,
134
+ accountId,
135
+ onAfterOptimisticRemove,
136
+ }: UseReportSpamOptions) {
137
+ const queryClient = useQueryClient();
138
+ const { pushError } = useErrorBanners();
139
+
140
+ const listPrefixes = threadListCacheKeys([mailboxId]);
141
+ const threadMessagesPrefix = threadId
142
+ ? threadDetailOperationsListThreadMessagesQueryKey({ path: { threadId } })
143
+ : [];
144
+
145
+ const invalidateAffectedQueries = () => {
146
+ if (threadId) {
147
+ queryClient.invalidateQueries({ queryKey: threadMessagesPrefix });
148
+ }
149
+ invalidateThreadListQueries(queryClient, listPrefixes);
150
+ if (accountId) {
151
+ queryClient.invalidateQueries({
152
+ queryKey: mailboxOperationsListMailboxesQueryKey({
153
+ path: { accountId },
154
+ }),
155
+ });
156
+ }
157
+ };
158
+
159
+ const buildOnSuccess =
160
+ () => (_data: unknown, variables: { body: { messageIds: string[] } }) => {
161
+ invalidateAffectedQueries();
162
+ onAfterOptimisticRemove?.(variables.body.messageIds);
163
+ };
164
+
165
+ const buildOnError =
166
+ (failureTitle: (count: number) => string) =>
167
+ (err: unknown, vars: { body: { messageIds: string[] } }) => {
168
+ pushError({
169
+ title: failureTitle(vars.body.messageIds.length),
170
+ detail: formatErrorDetail(err),
171
+ error: err,
172
+ });
173
+ };
174
+
175
+ const report = useMutation({
176
+ mutationFn: async (variables: { body: { messageIds: string[] } }) => {
177
+ const { data } = await messageBulkOperationsReportSpam({
178
+ ...variables,
179
+ throwOnError: true,
180
+ });
181
+ throwOnBulkFailure(data);
182
+ return data;
183
+ },
184
+ // A per-message report failure is a routine, expected, retryable outcome
185
+ // with backend-written user-facing copy — never the fatal overlay. See
186
+ // `throwOnBulkFailure`'s doc for why the thrown ApiError's status alone
187
+ // isn't enough: `shouldEscalate` defaults to escalate for a non-5xx that
188
+ // doesn't opt out here.
189
+ meta: { softError: true },
190
+ onSuccess: buildOnSuccess(),
191
+ onError: buildOnError((count) =>
192
+ count > 1
193
+ ? `Couldn't report ${count} messages as spam`
194
+ : "Couldn't report this message as spam",
195
+ ),
196
+ });
197
+
198
+ const restore = useMutation({
199
+ mutationFn: async (variables: { body: { messageIds: string[] } }) => {
200
+ const { data } = await messageBulkOperationsNotSpam({
201
+ ...variables,
202
+ throwOnError: true,
203
+ });
204
+ throwOnBulkFailure(data);
205
+ return data;
206
+ },
207
+ meta: { softError: true },
208
+ onSuccess: buildOnSuccess(),
209
+ onError: buildOnError((count) =>
210
+ count > 1
211
+ ? `Couldn't undo the spam report for ${count} messages`
212
+ : "Couldn't undo the spam report",
213
+ ),
214
+ });
215
+
216
+ const reportSpam = useCallback(
217
+ (messageIds: string[]) => {
218
+ if (messageIds.length === 0) return;
219
+ void runChunkedMutation(messageIds, (chunk) =>
220
+ report.mutateAsync({ body: { messageIds: chunk } }),
221
+ );
222
+ },
223
+ [report.mutateAsync],
224
+ );
225
+
226
+ const notSpam = useCallback(
227
+ (messageIds: string[]) => {
228
+ if (messageIds.length === 0) return;
229
+ void runChunkedMutation(messageIds, (chunk) =>
230
+ restore.mutateAsync({ body: { messageIds: chunk } }),
231
+ );
232
+ },
233
+ [restore.mutateAsync],
234
+ );
235
+
236
+ return {
237
+ reportSpam,
238
+ notSpam,
239
+ /** True while a report is in flight — wire into the "Report spam" quick action's pending state; a press with no visible response is the dead-button failure mode. */
240
+ isReporting: report.isPending,
241
+ /** True while an undo is in flight — wire into the "Not spam" quick action's pending state. */
242
+ isRestoring: restore.isPending,
243
+ };
244
+ }
@@ -7,6 +7,7 @@ import {
7
7
  autoMovedLabel,
8
8
  isAutoMoveInEffect,
9
9
  resolveUndoTargetMailboxId,
10
+ spamReportLabel,
10
11
  } from "./auto-moved.js";
11
12
 
12
13
  const ROLE_MAILBOXES: AutoMovedRoleMailboxes = {
@@ -79,6 +80,13 @@ describe("autoMovedLabel", () => {
79
80
  });
80
81
  });
81
82
 
83
+ describe("spamReportLabel (#648)", () => {
84
+ test("plain language, no folder or jargon", () => {
85
+ assert.equal(spamReportLabel, "Reported as spam");
86
+ assert.doesNotMatch(spamReportLabel, /confiden|dry.?run|verdict|junk/i);
87
+ });
88
+ });
89
+
82
90
  describe("isAutoMoveInEffect", () => {
83
91
  test("false when autoMoved is absent", () => {
84
92
  assert.equal(
@@ -72,6 +72,15 @@ export const isAutoMoveInEffect = (
72
72
  return destination !== undefined && destination === currentMailboxId;
73
73
  };
74
74
 
75
+ /**
76
+ * Plain-language label for a user-initiated spam report (issue #648).
77
+ * Deliberately not derived from placement the way `autoMovedLabel` is: a
78
+ * report on a message already in Junk (the provider's own filter put it
79
+ * there) is a real, no-op-move case, so the badge can't name a "from" folder
80
+ * the way a classifier/filter move can.
81
+ */
82
+ export const spamReportLabel = "Reported as spam";
83
+
75
84
  /**
76
85
  * Resolve the undo destination: where the message was before the move. A
77
86
  * standing-filter move recorded the exact source mailbox (`fromMailboxId`),