@remit/web-client 0.0.13 → 0.0.15
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 +1 -1
- package/src/auth/better-auth-config.ts +4 -1
- package/src/components/compose/ComposeForm.tsx +3 -0
- package/src/components/mail/IntelligencePane.tsx +10 -8
- package/src/components/mail/MailboxPane.tsx +53 -2
- package/src/components/mail/MessageActionMenu.tsx +18 -25
- package/src/components/mail/MessageDetail.tsx +1 -0
- package/src/components/mail/MessageList.tsx +196 -78
- package/src/components/mail/MessageListItem.tsx +60 -13
- package/src/components/mail/SwipeableMessageRow.tsx +8 -0
- package/src/components/ui/ConfirmDialog.tsx +9 -7
- package/src/components/ui/ErrorBanner.tsx +6 -3
- package/src/components/ui/ErrorBannerProvider.render.test.ts +85 -0
- package/src/components/ui/ErrorBannerProvider.tsx +18 -0
- package/src/components/ui/error-banners.ts +9 -0
- package/src/hooks/useDeleteMessages.ts +14 -21
- package/src/hooks/useIntelligenceData.ts +14 -3
- package/src/hooks/useMarkAsRead.ts +14 -23
- package/src/hooks/useMessageBodyContent.ts +2 -1
- package/src/hooks/useMoveMessages.ts +14 -21
- package/src/hooks/useStaleAccountSync.test.ts +22 -1
- package/src/hooks/useToggleStar.test.ts +31 -0
- package/src/hooks/useToggleStar.ts +27 -31
- package/src/hooks/useTriageKeyboard.ts +13 -8
- package/src/hooks/useUpdateAddressFlags.ts +3 -1
- package/src/lib/api.ts +3 -1
- package/src/lib/client.ts +3 -0
- package/src/lib/error-classifier.test.ts +156 -8
- package/src/lib/error-classifier.ts +49 -9
- package/src/lib/keymap-dispatch.test.ts +78 -1
- package/src/lib/keymap-dispatch.ts +64 -7
- package/src/lib/keymap.ts +23 -0
- package/src/lib/list-focus.test.ts +25 -0
- package/src/lib/list-focus.ts +24 -0
- package/src/lib/network-error.ts +58 -0
- package/src/lib/query-error-handler.test.ts +6 -2
- package/src/lib/query-escalation.integration.test.ts +3 -2
- package/src/lib/sender-address.test.ts +60 -0
- package/src/lib/sender-address.ts +37 -0
- package/src/lib/thread-cache.test.ts +65 -0
- package/src/lib/thread-cache.ts +76 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, describe, it } from "node:test";
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { ApiError } from "@/lib/api";
|
|
6
|
+
import { __resetFatalError, getCurrentFatalError } from "@/lib/fatal-error";
|
|
7
|
+
import { ErrorBannerProvider, useErrorBanners } from "./ErrorBannerProvider";
|
|
8
|
+
import type { PushErrorInput } from "./error-banners";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Push one error through the real provider and report where it went. The
|
|
12
|
+
* routing decision happens inside `pushError`, so a single render is enough:
|
|
13
|
+
* either a fatal was recorded, or the error stayed a soft banner.
|
|
14
|
+
*/
|
|
15
|
+
const push = (input: PushErrorInput): { escalated: boolean } => {
|
|
16
|
+
const Pusher = () => {
|
|
17
|
+
useErrorBanners().pushError(input);
|
|
18
|
+
return null;
|
|
19
|
+
};
|
|
20
|
+
renderToString(
|
|
21
|
+
createElement(ErrorBannerProvider, null, createElement(Pusher)) as never,
|
|
22
|
+
);
|
|
23
|
+
return { escalated: getCurrentFatalError() !== null };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
afterEach(() => {
|
|
27
|
+
__resetFatalError();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe("pushError — a banner is a soft surface only", () => {
|
|
31
|
+
it("keeps a soft failure with no error attached in a banner", () => {
|
|
32
|
+
assert.equal(push({ title: "Couldn't save draft" }).escalated, false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("treats an absent error as absent, not as a bug", () => {
|
|
36
|
+
// A caller forwarding an optional error it does not have passes
|
|
37
|
+
// `undefined`. That is "nothing went wrong that we can classify", not a
|
|
38
|
+
// client-side exception, and must not take over the screen.
|
|
39
|
+
assert.equal(
|
|
40
|
+
push({ title: "Couldn't save draft", error: undefined }).escalated,
|
|
41
|
+
false,
|
|
42
|
+
);
|
|
43
|
+
assert.equal(
|
|
44
|
+
push({ title: "Couldn't save draft", error: null }).escalated,
|
|
45
|
+
false,
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("keeps a 4xx the call site owns in a banner", () => {
|
|
50
|
+
assert.equal(
|
|
51
|
+
push({
|
|
52
|
+
title: "Couldn't move this message",
|
|
53
|
+
error: new ApiError("Not found", 404),
|
|
54
|
+
}).escalated,
|
|
55
|
+
false,
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it("escalates a 5xx to the fatal page instead of a toast", () => {
|
|
60
|
+
assert.equal(
|
|
61
|
+
push({
|
|
62
|
+
title: "Couldn't move 339 messages",
|
|
63
|
+
error: new ApiError("Internal error", 500),
|
|
64
|
+
}).escalated,
|
|
65
|
+
true,
|
|
66
|
+
);
|
|
67
|
+
assert.equal(getCurrentFatalError()?.message, "Internal error");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("escalates an exception thrown by our own code (issue #55)", () => {
|
|
71
|
+
const bug = new TypeError(
|
|
72
|
+
'can\'t access property "map", N.pages is undefined',
|
|
73
|
+
);
|
|
74
|
+
assert.equal(
|
|
75
|
+
push({ title: "Couldn't move 339 messages", error: bug }).escalated,
|
|
76
|
+
true,
|
|
77
|
+
);
|
|
78
|
+
assert.equal(getCurrentFatalError()?.message, bug.message);
|
|
79
|
+
assert.equal(
|
|
80
|
+
getCurrentFatalError()?.recoverable,
|
|
81
|
+
false,
|
|
82
|
+
"a bug is not something the user can retry away",
|
|
83
|
+
);
|
|
84
|
+
});
|
|
85
|
+
});
|
|
@@ -6,6 +6,8 @@ import {
|
|
|
6
6
|
useMemo,
|
|
7
7
|
useState,
|
|
8
8
|
} from "react";
|
|
9
|
+
import { isAlwaysFatal } from "@/lib/error-classifier";
|
|
10
|
+
import { reportFatalError } from "@/lib/fatal-error";
|
|
9
11
|
import { ErrorBannerStack } from "./ErrorBannerStack.js";
|
|
10
12
|
import {
|
|
11
13
|
appendBanner,
|
|
@@ -39,7 +41,23 @@ const generateId = (): string => {
|
|
|
39
41
|
export const ErrorBannerProvider = ({ children }: { children: ReactNode }) => {
|
|
40
42
|
const [errors, setErrors] = useState<ErrorBannerEntry[]>([]);
|
|
41
43
|
|
|
44
|
+
/**
|
|
45
|
+
* Show a soft, dismissible banner — and only that. A fatal error is not a
|
|
46
|
+
* notification: when the caller hands us a 5xx or an exception from our own
|
|
47
|
+
* code, it goes to the fatal seam instead, so the user gets the full-screen
|
|
48
|
+
* page with a way forward and a bug report rather than a toast they can only
|
|
49
|
+
* dismiss (issue #55). Those two classes are fatal with no opt-out; anything
|
|
50
|
+
* a call site can legitimately own — a 404 empty state, a 4xx the user can
|
|
51
|
+
* act on — still banners here.
|
|
52
|
+
*
|
|
53
|
+
* The guard is on the error being present, not on the key being present:
|
|
54
|
+
* `error` is optional, so a caller forwarding one it does not have passes
|
|
55
|
+
* `undefined`, and "no error at all" must not be classified as a bug.
|
|
56
|
+
*/
|
|
42
57
|
const pushError = useCallback((input: PushErrorInput): string => {
|
|
58
|
+
if (input.error != null && isAlwaysFatal(input.error)) {
|
|
59
|
+
return reportFatalError(input.error).correlationId;
|
|
60
|
+
}
|
|
43
61
|
const entry = buildEntry(input, generateId(), Date.now());
|
|
44
62
|
setErrors((current) => appendBanner(current, entry));
|
|
45
63
|
return entry.id;
|
|
@@ -12,6 +12,14 @@ export interface PushErrorInput {
|
|
|
12
12
|
severity?: ErrorBannerSeverity;
|
|
13
13
|
title: string;
|
|
14
14
|
detail?: string;
|
|
15
|
+
/**
|
|
16
|
+
* The error being reported, when there is one. Pass it: a banner is a soft
|
|
17
|
+
* surface, and `pushError` uses this to refuse errors that are not soft —
|
|
18
|
+
* a 5xx or a client-side exception is escalated to the full-screen fatal
|
|
19
|
+
* page (which offers Retry, a way out, and a bug report) instead of being
|
|
20
|
+
* reduced to a dismissible toast.
|
|
21
|
+
*/
|
|
22
|
+
error?: unknown;
|
|
15
23
|
}
|
|
16
24
|
|
|
17
25
|
const MAX_BANNERS = 5;
|
|
@@ -30,6 +38,7 @@ export const buildMutationErrorBanner = (
|
|
|
30
38
|
): PushErrorInput => ({
|
|
31
39
|
title,
|
|
32
40
|
detail: formatErrorDetail(error) ?? fallback,
|
|
41
|
+
error,
|
|
33
42
|
});
|
|
34
43
|
|
|
35
44
|
export const formatErrorDetail = (error: unknown): string | undefined => {
|
|
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
10
10
|
import { useCallback } from "react";
|
|
11
11
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
12
12
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
13
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
13
14
|
|
|
14
15
|
interface UseDeleteMessagesOptions {
|
|
15
16
|
mailboxId: string;
|
|
@@ -28,15 +29,12 @@ interface ThreadMessagesData {
|
|
|
28
29
|
[key: string]: unknown;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
pages: ThreadsListPage[];
|
|
38
|
-
pageParams: Array<string | undefined>;
|
|
39
|
-
}
|
|
32
|
+
/**
|
|
33
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
34
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
35
|
+
* optimistic updater sees both.
|
|
36
|
+
*/
|
|
37
|
+
type ThreadsListData = ThreadListCache;
|
|
40
38
|
|
|
41
39
|
interface SnapshotEntry<T> {
|
|
42
40
|
queryKey: readonly unknown[];
|
|
@@ -147,22 +145,16 @@ export const useDeleteMessages = ({
|
|
|
147
145
|
);
|
|
148
146
|
}
|
|
149
147
|
|
|
150
|
-
const patchListData = (old:
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
pages: old.pages.map((page) => ({
|
|
155
|
-
...page,
|
|
156
|
-
items: removeMessagesFromItems(page.items, messageIds),
|
|
157
|
-
})),
|
|
158
|
-
};
|
|
159
|
-
};
|
|
148
|
+
const patchListData = (old: unknown) =>
|
|
149
|
+
patchThreadListCache(old, (items) =>
|
|
150
|
+
removeMessagesFromItems(items, messageIds),
|
|
151
|
+
);
|
|
160
152
|
|
|
161
|
-
queryClient.setQueriesData
|
|
153
|
+
queryClient.setQueriesData(
|
|
162
154
|
{ queryKey: threadsListPrefix },
|
|
163
155
|
patchListData,
|
|
164
156
|
);
|
|
165
|
-
queryClient.setQueriesData
|
|
157
|
+
queryClient.setQueriesData(
|
|
166
158
|
{ queryKey: threadsSearchPrefix },
|
|
167
159
|
patchListData,
|
|
168
160
|
);
|
|
@@ -193,6 +185,7 @@ export const useDeleteMessages = ({
|
|
|
193
185
|
? `Couldn't delete ${count} messages`
|
|
194
186
|
: "Couldn't delete this message",
|
|
195
187
|
detail: formatErrorDetail(err),
|
|
188
|
+
error: err,
|
|
196
189
|
});
|
|
197
190
|
},
|
|
198
191
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -18,6 +18,10 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
18
18
|
import { useMemo } from "react";
|
|
19
19
|
import { isServerError } from "@/lib/error-classifier";
|
|
20
20
|
import { formatDate, formatEmailDate } from "@/lib/format";
|
|
21
|
+
import {
|
|
22
|
+
pickSenderAddress,
|
|
23
|
+
senderAddressSearchQuery,
|
|
24
|
+
} from "@/lib/sender-address";
|
|
21
25
|
|
|
22
26
|
/**
|
|
23
27
|
* Format a creation timestamp as a human-readable "first seen" label.
|
|
@@ -180,7 +184,11 @@ export interface UseIntelligenceDataResult {
|
|
|
180
184
|
* that misrepresents the sender's real state. Escalation covers it.
|
|
181
185
|
*/
|
|
182
186
|
addressErrorIsFatal: boolean;
|
|
183
|
-
/**
|
|
187
|
+
/**
|
|
188
|
+
* Address id for the sender — needed for PATCH /addresses/{id} mutations.
|
|
189
|
+
* `undefined` until the lookup resolves the row for exactly this address;
|
|
190
|
+
* quick actions that PATCH it must stay unwired until then.
|
|
191
|
+
*/
|
|
184
192
|
addressId: string | undefined;
|
|
185
193
|
/**
|
|
186
194
|
* Raw address response for the sender — forwarded to the quick-action
|
|
@@ -209,13 +217,16 @@ export function useIntelligenceData(
|
|
|
209
217
|
// --- Address lookup: flags + first-seen timestamp ---
|
|
210
218
|
const { data: addressSearchResult, error: addressError } = useQuery({
|
|
211
219
|
...addressOperationsSearchAddressesOptions({
|
|
212
|
-
query:
|
|
220
|
+
query: senderAddressSearchQuery(senderEmail ?? undefined),
|
|
213
221
|
}),
|
|
214
222
|
enabled: Boolean(senderEmail),
|
|
215
223
|
staleTime: 30_000,
|
|
216
224
|
});
|
|
217
225
|
|
|
218
|
-
const address =
|
|
226
|
+
const address = pickSenderAddress(
|
|
227
|
+
addressSearchResult?.items,
|
|
228
|
+
senderEmail ?? undefined,
|
|
229
|
+
);
|
|
219
230
|
|
|
220
231
|
// --- Semantic search: similar messages ---
|
|
221
232
|
const semanticQuery = [subject, senderEmail].filter(Boolean).join(" ");
|
|
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
10
10
|
import { useCallback, useEffect, useMemo, useRef } from "react";
|
|
11
11
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
12
12
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
13
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
13
14
|
|
|
14
15
|
interface UseMarkAsReadOptions {
|
|
15
16
|
messages: RemitImapThreadMessageResponse[];
|
|
@@ -24,15 +25,12 @@ interface ThreadMessagesData {
|
|
|
24
25
|
[key: string]: unknown;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
pages: ThreadsListPage[];
|
|
34
|
-
pageParams: Array<string | undefined>;
|
|
35
|
-
}
|
|
28
|
+
/**
|
|
29
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
30
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
31
|
+
* optimistic updater sees both.
|
|
32
|
+
*/
|
|
33
|
+
type ThreadsListData = ThreadListCache;
|
|
36
34
|
|
|
37
35
|
interface SnapshotEntry<T> {
|
|
38
36
|
queryKey: readonly unknown[];
|
|
@@ -178,25 +176,16 @@ export const useMarkAsRead = ({
|
|
|
178
176
|
},
|
|
179
177
|
);
|
|
180
178
|
|
|
181
|
-
const patchListData = (old:
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
pages: old.pages.map((page) => ({
|
|
186
|
-
...page,
|
|
187
|
-
items: setReadOnItems(page.items, messageIds, isRead),
|
|
188
|
-
})),
|
|
189
|
-
};
|
|
190
|
-
};
|
|
179
|
+
const patchListData = (old: unknown) =>
|
|
180
|
+
patchThreadListCache(old, (items) =>
|
|
181
|
+
setReadOnItems(items, messageIds, isRead),
|
|
182
|
+
);
|
|
191
183
|
|
|
192
184
|
for (const queryKey of [
|
|
193
185
|
...threadsListPrefixes,
|
|
194
186
|
...threadsSearchPrefixes,
|
|
195
187
|
]) {
|
|
196
|
-
queryClient.setQueriesData
|
|
197
|
-
{ queryKey },
|
|
198
|
-
patchListData,
|
|
199
|
-
);
|
|
188
|
+
queryClient.setQueriesData({ queryKey }, patchListData);
|
|
200
189
|
}
|
|
201
190
|
|
|
202
191
|
return {
|
|
@@ -231,6 +220,7 @@ export const useMarkAsRead = ({
|
|
|
231
220
|
pushError({
|
|
232
221
|
title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
|
|
233
222
|
detail: formatErrorDetail(error),
|
|
223
|
+
error,
|
|
234
224
|
});
|
|
235
225
|
},
|
|
236
226
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -321,6 +311,7 @@ export const useToggleReadFor = (options: {
|
|
|
321
311
|
pushError({
|
|
322
312
|
title: isRead ? "Couldn't mark as read" : "Couldn't mark as unread",
|
|
323
313
|
detail: formatErrorDetail(error),
|
|
314
|
+
error,
|
|
324
315
|
});
|
|
325
316
|
},
|
|
326
317
|
onSettled: () => {
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
pickRenderablePart,
|
|
8
8
|
type RenderableBodyPart,
|
|
9
9
|
} from "@/lib/message-body-source";
|
|
10
|
+
import { taggedFetch } from "@/lib/network-error";
|
|
10
11
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
11
12
|
import { messageKeys } from "./queries/keys";
|
|
12
13
|
|
|
@@ -186,7 +187,7 @@ export const fetchBodyContent = async (
|
|
|
186
187
|
const headers: Record<string, string> = {};
|
|
187
188
|
const token = await getToken();
|
|
188
189
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
189
|
-
const response = await
|
|
190
|
+
const response = await taggedFetch(url, { headers });
|
|
190
191
|
// 202 = the body is not synced yet. The content route has re-armed the sync
|
|
191
192
|
// cue; retry after `Retry-After` seconds rather than rendering the placeholder
|
|
192
193
|
// body (a 202 is `ok`, so this must be caught before the success path).
|
|
@@ -10,6 +10,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
10
10
|
import { useCallback } from "react";
|
|
11
11
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
12
12
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
13
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
13
14
|
|
|
14
15
|
interface UseMoveMessagesOptions {
|
|
15
16
|
mailboxId: string;
|
|
@@ -28,15 +29,12 @@ interface ThreadMessagesData {
|
|
|
28
29
|
[key: string]: unknown;
|
|
29
30
|
}
|
|
30
31
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
pages: ThreadsListPage[];
|
|
38
|
-
pageParams: Array<string | undefined>;
|
|
39
|
-
}
|
|
32
|
+
/**
|
|
33
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
34
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
35
|
+
* optimistic updater sees both.
|
|
36
|
+
*/
|
|
37
|
+
type ThreadsListData = ThreadListCache;
|
|
40
38
|
|
|
41
39
|
interface SnapshotEntry<T> {
|
|
42
40
|
queryKey: readonly unknown[];
|
|
@@ -142,22 +140,16 @@ export const useMoveMessages = ({
|
|
|
142
140
|
);
|
|
143
141
|
}
|
|
144
142
|
|
|
145
|
-
const patchListData = (old:
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
pages: old.pages.map((page) => ({
|
|
150
|
-
...page,
|
|
151
|
-
items: removeMovedMessagesFromItems(page.items, messageIds),
|
|
152
|
-
})),
|
|
153
|
-
};
|
|
154
|
-
};
|
|
143
|
+
const patchListData = (old: unknown) =>
|
|
144
|
+
patchThreadListCache(old, (items) =>
|
|
145
|
+
removeMovedMessagesFromItems(items, messageIds),
|
|
146
|
+
);
|
|
155
147
|
|
|
156
|
-
queryClient.setQueriesData
|
|
148
|
+
queryClient.setQueriesData(
|
|
157
149
|
{ queryKey: threadsListPrefix },
|
|
158
150
|
patchListData,
|
|
159
151
|
);
|
|
160
|
-
queryClient.setQueriesData
|
|
152
|
+
queryClient.setQueriesData(
|
|
161
153
|
{ queryKey: threadsSearchPrefix },
|
|
162
154
|
patchListData,
|
|
163
155
|
);
|
|
@@ -188,6 +180,7 @@ export const useMoveMessages = ({
|
|
|
188
180
|
? `Couldn't move ${count} messages`
|
|
189
181
|
: "Couldn't move this message",
|
|
190
182
|
detail: formatErrorDetail(err),
|
|
183
|
+
error: err,
|
|
191
184
|
});
|
|
192
185
|
},
|
|
193
186
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { afterEach, describe, test } from "node:test";
|
|
3
3
|
import { ApiError } from "../lib/api.js";
|
|
4
4
|
import { __resetFatalError, subscribeFatalError } from "../lib/fatal-error.js";
|
|
5
|
+
import { NetworkError } from "../lib/network-error.js";
|
|
5
6
|
import {
|
|
6
7
|
__peekStaleAccountSyncGuard,
|
|
7
8
|
__resetStaleAccountSyncGuard,
|
|
@@ -227,11 +228,31 @@ describe("handleBackgroundSyncFailure", () => {
|
|
|
227
228
|
escalated = true;
|
|
228
229
|
});
|
|
229
230
|
|
|
230
|
-
|
|
231
|
+
// The probe goes through the generated client, so a real transport
|
|
232
|
+
// failure arrives tagged by the fetch boundary.
|
|
233
|
+
handleBackgroundSyncFailure(
|
|
234
|
+
"a-1",
|
|
235
|
+
new NetworkError(new TypeError("Failed to fetch")),
|
|
236
|
+
);
|
|
231
237
|
|
|
232
238
|
assert.equal(escalated, false);
|
|
233
239
|
});
|
|
234
240
|
|
|
241
|
+
test("an exception from our own code on the probe DOES escalate", () => {
|
|
242
|
+
silenceWarn();
|
|
243
|
+
let escalated = false;
|
|
244
|
+
subscribeFatalError(() => {
|
|
245
|
+
escalated = true;
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
// Untagged and statusless: nothing said this came off the wire, so it is
|
|
249
|
+
// our bug, and a silent background probe is exactly where one would
|
|
250
|
+
// otherwise go unnoticed.
|
|
251
|
+
handleBackgroundSyncFailure("a-1", new TypeError("x is not a function"));
|
|
252
|
+
|
|
253
|
+
assert.equal(escalated, true);
|
|
254
|
+
});
|
|
255
|
+
|
|
235
256
|
test("drops the per-account guard so a later remount can retry", () => {
|
|
236
257
|
silenceWarn();
|
|
237
258
|
__peekStaleAccountSyncGuard(); // touch to ensure import is used
|
|
@@ -1,6 +1,7 @@
|
|
|
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 { patchThreadListCache } from "../lib/thread-cache.js";
|
|
4
5
|
import {
|
|
5
6
|
resolveMailboxForMessage,
|
|
6
7
|
toggleStarsInItems,
|
|
@@ -93,3 +94,33 @@ describe("resolveMailboxForMessage", () => {
|
|
|
93
94
|
);
|
|
94
95
|
});
|
|
95
96
|
});
|
|
97
|
+
|
|
98
|
+
describe("starring across the unified-threads cache shapes", () => {
|
|
99
|
+
// The Flagged view runs an infinite query on `listAllThreads({ starred:
|
|
100
|
+
// true })`, which shares its query-key prefix with the single-shot readers.
|
|
101
|
+
// A `setQueriesData` on that prefix therefore sees both shapes, and the
|
|
102
|
+
// optimistic patch has to survive whichever it is handed — patching the
|
|
103
|
+
// infinite entry as a plain `{ items }` threw and failed the star before it
|
|
104
|
+
// was sent.
|
|
105
|
+
const patch = (old: unknown) =>
|
|
106
|
+
patchThreadListCache(old, (items) => toggleStarsInItems(items, "m1", true));
|
|
107
|
+
|
|
108
|
+
test("patches the single-shot readers", () => {
|
|
109
|
+
const patched = patch({
|
|
110
|
+
items: [make("m1", false), make("m2", false)],
|
|
111
|
+
}) as {
|
|
112
|
+
items: RemitImapThreadMessageResponse[];
|
|
113
|
+
};
|
|
114
|
+
assert.equal(patched.items[0].hasStars, true);
|
|
115
|
+
assert.equal(patched.items[1].hasStars, false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("patches the Flagged view's infinite query instead of throwing", () => {
|
|
119
|
+
const patched = patch({
|
|
120
|
+
pages: [{ items: [make("m1", false)] }, { items: [make("m2", false)] }],
|
|
121
|
+
pageParams: [undefined, "next"],
|
|
122
|
+
}) as { pages: Array<{ items: RemitImapThreadMessageResponse[] }> };
|
|
123
|
+
assert.equal(patched.pages[0].items[0].hasStars, true);
|
|
124
|
+
assert.equal(patched.pages[1].items[0].hasStars, false);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -9,6 +9,7 @@ import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/type
|
|
|
9
9
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
10
10
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
11
11
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
12
|
+
import { patchThreadListCache, type ThreadListCache } from "@/lib/thread-cache";
|
|
12
13
|
|
|
13
14
|
interface UseToggleStarOptions {
|
|
14
15
|
threadId: string;
|
|
@@ -46,15 +47,12 @@ interface ThreadMessagesData {
|
|
|
46
47
|
[key: string]: unknown;
|
|
47
48
|
}
|
|
48
49
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
pages: ThreadsListPage[];
|
|
56
|
-
pageParams: Array<string | undefined>;
|
|
57
|
-
}
|
|
50
|
+
/**
|
|
51
|
+
* Either shape a thread list/search query caches — the infinite mailbox list
|
|
52
|
+
* or a single-shot page. `setQueriesData` matches by key prefix, so the
|
|
53
|
+
* optimistic updater sees both.
|
|
54
|
+
*/
|
|
55
|
+
type ThreadsListData = ThreadListCache;
|
|
58
56
|
|
|
59
57
|
interface SnapshotEntry<T> {
|
|
60
58
|
queryKey: readonly unknown[];
|
|
@@ -156,39 +154,36 @@ export const useToggleStar = ({
|
|
|
156
154
|
)
|
|
157
155
|
.map(([queryKey, data]) => ({ queryKey, data }));
|
|
158
156
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
157
|
+
// The unified-threads prefix carries both shapes too: the Flagged view
|
|
158
|
+
// runs an infinite query on `listAllThreads({ starred: true })`, which
|
|
159
|
+
// sits under the same prefix as the single-shot readers. Patching it as
|
|
160
|
+
// a plain `{ items }` threw on that entry and failed the star before it
|
|
161
|
+
// was sent — the same defect as the mailbox list (issues #51, #55), so
|
|
162
|
+
// it goes through the same helper.
|
|
163
|
+
const patchItemsData = (old: unknown) =>
|
|
164
|
+
patchThreadListCache(old, (items) =>
|
|
165
|
+
toggleStarsInItems(items, messageId, nextStarred),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
queryClient.setQueriesData(
|
|
168
169
|
{ queryKey: threadMessagesPrefix },
|
|
169
170
|
patchItemsData,
|
|
170
171
|
);
|
|
171
|
-
queryClient.setQueriesData
|
|
172
|
+
queryClient.setQueriesData(
|
|
172
173
|
{ queryKey: unifiedThreadsPrefix },
|
|
173
174
|
patchItemsData,
|
|
174
175
|
);
|
|
175
176
|
|
|
176
|
-
const patchListData = (old:
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
pages: old.pages.map((page) => ({
|
|
181
|
-
...page,
|
|
182
|
-
items: toggleStarsInItems(page.items, messageId, nextStarred),
|
|
183
|
-
})),
|
|
184
|
-
};
|
|
185
|
-
};
|
|
177
|
+
const patchListData = (old: unknown) =>
|
|
178
|
+
patchThreadListCache(old, (items) =>
|
|
179
|
+
toggleStarsInItems(items, messageId, nextStarred),
|
|
180
|
+
);
|
|
186
181
|
|
|
187
|
-
queryClient.setQueriesData
|
|
182
|
+
queryClient.setQueriesData(
|
|
188
183
|
{ queryKey: threadsListPrefix },
|
|
189
184
|
patchListData,
|
|
190
185
|
);
|
|
191
|
-
queryClient.setQueriesData
|
|
186
|
+
queryClient.setQueriesData(
|
|
192
187
|
{ queryKey: threadsSearchPrefix },
|
|
193
188
|
patchListData,
|
|
194
189
|
);
|
|
@@ -221,6 +216,7 @@ export const useToggleStar = ({
|
|
|
221
216
|
? "Couldn't star message"
|
|
222
217
|
: "Couldn't unstar message",
|
|
223
218
|
detail: formatErrorDetail(err),
|
|
219
|
+
error: err,
|
|
224
220
|
});
|
|
225
221
|
},
|
|
226
222
|
onSettled: (_data, _err, _vars, context) => {
|
|
@@ -2,6 +2,7 @@ import { useEffect, useRef } from "react";
|
|
|
2
2
|
import type { TriageAction } from "@/lib/keymap";
|
|
3
3
|
import {
|
|
4
4
|
dispatchKey,
|
|
5
|
+
isControlTarget,
|
|
5
6
|
isEditableTarget,
|
|
6
7
|
type SequencePrefix,
|
|
7
8
|
} from "@/lib/keymap-dispatch";
|
|
@@ -27,14 +28,17 @@ interface UseTriageKeyboardOptions {
|
|
|
27
28
|
* even Esc is left to the focused field's own handler) and carrying the `g …`
|
|
28
29
|
* go-to sequence prefix across keystrokes with a timeout.
|
|
29
30
|
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
31
|
+
* List navigation and selection route through here and nowhere else: the
|
|
32
|
+
* message list publishes its commands upward (see `MessageListCommands`) and
|
|
33
|
+
* the route wires them into the handler table, so `@/lib/keymap` is the source
|
|
34
|
+
* of truth for both the displayed bindings and the routed ones. The list used
|
|
35
|
+
* to run a second window listener claiming the same keys, which is what made
|
|
36
|
+
* Enter unusable on every focused button in the app (#43).
|
|
37
|
+
*
|
|
38
|
+
* Other window-level keydown listeners still exist for keys this layer does not
|
|
39
|
+
* own — `?` at the mail layout, `/` in SearchBar, Esc in the compose and
|
|
40
|
+
* conversation views. They bind disjoint keys; only the list's competing
|
|
41
|
+
* listener was removed.
|
|
38
42
|
*
|
|
39
43
|
* Per-action targeting (focused row vs selection) and the actual mutations live
|
|
40
44
|
* in the handlers the caller passes in — this hook only dispatches.
|
|
@@ -70,6 +74,7 @@ export function useTriageKeyboard({
|
|
|
70
74
|
ctrlKey: event.ctrlKey,
|
|
71
75
|
altKey: event.altKey,
|
|
72
76
|
inEditable: isEditableTarget(event.target),
|
|
77
|
+
onControl: isControlTarget(event.target),
|
|
73
78
|
},
|
|
74
79
|
prefixRef.current,
|
|
75
80
|
);
|
|
@@ -12,6 +12,7 @@ import { useCallback } from "react";
|
|
|
12
12
|
import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
|
|
13
13
|
import { formatErrorDetail } from "@/components/ui/error-banners";
|
|
14
14
|
import { reportFatalError } from "@/lib/fatal-error";
|
|
15
|
+
import { senderAddressSearchQuery } from "@/lib/sender-address";
|
|
15
16
|
|
|
16
17
|
interface UseUpdateAddressFlagsOptions {
|
|
17
18
|
addressId: string | undefined;
|
|
@@ -61,7 +62,7 @@ export function useUpdateAddressFlags({
|
|
|
61
62
|
const { pushError } = useErrorBanners();
|
|
62
63
|
|
|
63
64
|
const addressCacheKey = addressOperationsSearchAddressesQueryKey({
|
|
64
|
-
query:
|
|
65
|
+
query: senderAddressSearchQuery(senderEmail),
|
|
65
66
|
});
|
|
66
67
|
|
|
67
68
|
const { mutate, isPending } = useMutation({
|
|
@@ -99,6 +100,7 @@ export function useUpdateAddressFlags({
|
|
|
99
100
|
pushError({
|
|
100
101
|
title: "Couldn't update sender preference",
|
|
101
102
|
detail: formatErrorDetail(err),
|
|
103
|
+
error: err,
|
|
102
104
|
});
|
|
103
105
|
},
|
|
104
106
|
onSettled: () => {
|