@remit/web-client 0.0.272 → 0.0.274
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/components/mail/BlockedImagesNotice.render.test.ts +54 -5
- package/src/components/mail/BlockedImagesNotice.tsx +40 -18
- package/src/components/mail/BulkRunProvider.tsx +14 -1
- package/src/components/mail/MessageBody.blocked-sender.test.ts +193 -0
- package/src/components/mail/MessageBody.tsx +21 -2
- package/src/components/mail/MessageCard.blocked-sender.test.ts +246 -0
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageList.run-reporting.test.ts +1 -0
- package/src/components/mail/MessageListItem.test.ts +59 -3
- package/src/components/mail/MessageSettlementAlert.render.test.ts +182 -0
- package/src/components/mail/MessageSettlementAlert.tsx +77 -19
- package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
- package/src/components/mail/bulk-run-state.test.ts +1 -0
- package/src/components/mail/conversation-reply-identity.render.test.ts +1 -0
- package/src/components/mail/conversation-reply-reach.render.test.ts +1 -0
- package/src/components/mail/selection-wizard-run-controls.test.ts +1 -0
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +1 -0
- package/src/components/ui/placement-refusal.test.ts +7 -20
- package/src/components/ui/placement-refusal.ts +10 -12
- package/src/hooks/useDeleteMessages.ts +19 -0
- package/src/hooks/useMailboxAccount.ts +5 -0
- package/src/hooks/useToggleStar.test.ts +1 -0
- package/src/hooks/useToggleTrusted.test.ts +1 -0
- package/src/lib/brief.test.ts +1 -0
- package/src/lib/bulk-actions.test.ts +29 -1
- package/src/lib/bulk-actions.ts +59 -16
- package/src/lib/dedupe-thread-messages.test.ts +1 -0
- package/src/lib/row-settlement.ts +21 -8
- package/src/lib/thread-search-tokens.test.ts +1 -0
- package/src/test-support/fixtures.ts +1 -0
|
@@ -200,6 +200,12 @@ const ExpandedCard = ({
|
|
|
200
200
|
const isUnread = !threadMessage.isRead;
|
|
201
201
|
const isTrusted =
|
|
202
202
|
messageData?.envelope.from[0]?.flags?.trusted?.value === true;
|
|
203
|
+
// `BlockedFlag`: never load images, even on explicit click. Read alongside
|
|
204
|
+
// `trusted` and handed to the body, which lets blocked win over trusted —
|
|
205
|
+
// including on the header, where a trusted badge beside "you blocked this
|
|
206
|
+
// sender" would tell the user two opposite things about the same address.
|
|
207
|
+
const isBlocked =
|
|
208
|
+
messageData?.envelope.from[0]?.flags?.blocked?.value === true;
|
|
203
209
|
const fromAddressId = messageData?.envelope.from[0]?.addressId;
|
|
204
210
|
const message = toThreadMessageData(threadMessage, date);
|
|
205
211
|
|
|
@@ -220,7 +226,7 @@ const ExpandedCard = ({
|
|
|
220
226
|
message={message}
|
|
221
227
|
isFocused={mobile ? false : isFocused}
|
|
222
228
|
onHeaderClick={onToggle}
|
|
223
|
-
senderBadge={isTrusted ? <TrustedSenderBadge /> : undefined}
|
|
229
|
+
senderBadge={isTrusted && !isBlocked ? <TrustedSenderBadge /> : undefined}
|
|
224
230
|
trailing={
|
|
225
231
|
<span data-testid="message-date" className="text-2xs text-fg-subtle">
|
|
226
232
|
{date}
|
|
@@ -328,6 +334,7 @@ const ExpandedCard = ({
|
|
|
328
334
|
messageId={threadMessage.messageId}
|
|
329
335
|
fromAddressId={messageData?.envelope.from[0]?.addressId}
|
|
330
336
|
isTrusted={isTrusted}
|
|
337
|
+
isBlocked={isBlocked}
|
|
331
338
|
category={toDisplayCategory(threadMessage.category)}
|
|
332
339
|
/>
|
|
333
340
|
<MessageAttachments
|
|
@@ -21,6 +21,7 @@ const baseThread = (
|
|
|
21
21
|
star: "None",
|
|
22
22
|
isDeleted: false,
|
|
23
23
|
senderTrust: "unknown",
|
|
24
|
+
abandonedMutation: "none",
|
|
24
25
|
createdAt: 0,
|
|
25
26
|
updatedAt: 0,
|
|
26
27
|
...overrides,
|
|
@@ -47,11 +48,58 @@ describe("threadToRowData — labels", () => {
|
|
|
47
48
|
describe("threadToRowData — settlement (issue #1002)", () => {
|
|
48
49
|
it("marks a row whose delete gave up and came back", () => {
|
|
49
50
|
const row = threadToRowData(
|
|
50
|
-
baseThread({
|
|
51
|
+
baseThread({
|
|
52
|
+
status: "active",
|
|
53
|
+
syncStatus: "abandoned",
|
|
54
|
+
abandonedMutation: "delete",
|
|
55
|
+
}),
|
|
51
56
|
);
|
|
52
57
|
assert.equal(row.settlement, "delete_failed");
|
|
53
58
|
});
|
|
54
59
|
|
|
60
|
+
// Issue #1229. Both give-ups hand the row back the same way, so before the
|
|
61
|
+
// row named the mutation this rendered "Not deleted" over a move.
|
|
62
|
+
it("marks a row whose move gave up as a move, not a delete", () => {
|
|
63
|
+
const row = threadToRowData(
|
|
64
|
+
baseThread({
|
|
65
|
+
status: "active",
|
|
66
|
+
syncStatus: "abandoned",
|
|
67
|
+
abandonedMutation: "move",
|
|
68
|
+
}),
|
|
69
|
+
);
|
|
70
|
+
assert.equal(row.settlement, "move_failed");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("says nothing about a copy that gave up, whose row no listing carries", () => {
|
|
74
|
+
const row = threadToRowData(
|
|
75
|
+
baseThread({
|
|
76
|
+
status: "deleted",
|
|
77
|
+
syncStatus: "abandoned",
|
|
78
|
+
abandonedMutation: "copy",
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
assert.equal(row.settlement, undefined);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// `syncStatus` is the gate: a marker a later settle left behind says nothing.
|
|
85
|
+
it("says nothing about a settled row still carrying a stale marker", () => {
|
|
86
|
+
const row = threadToRowData(
|
|
87
|
+
baseThread({
|
|
88
|
+
status: "active",
|
|
89
|
+
syncStatus: "synced",
|
|
90
|
+
abandonedMutation: "delete",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
assert.equal(row.settlement, undefined);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("says nothing about a transient attempt on an active row", () => {
|
|
97
|
+
const row = threadToRowData(
|
|
98
|
+
baseThread({ status: "active", syncStatus: "failed" }),
|
|
99
|
+
);
|
|
100
|
+
assert.equal(row.settlement, undefined);
|
|
101
|
+
});
|
|
102
|
+
|
|
55
103
|
it("says nothing about a move mid-retry, which writes the same failed flag", () => {
|
|
56
104
|
const row = threadToRowData(
|
|
57
105
|
baseThread({ status: "moving", syncStatus: "failed" }),
|
|
@@ -77,14 +125,22 @@ describe("threadToRowData — settlement (issue #1002)", () => {
|
|
|
77
125
|
describe("the mobile and drafts rows carry the same mark", () => {
|
|
78
126
|
it("marks the swipeable mobile row", () => {
|
|
79
127
|
const row = swipeableRowData(
|
|
80
|
-
baseThread({
|
|
128
|
+
baseThread({
|
|
129
|
+
status: "active",
|
|
130
|
+
syncStatus: "abandoned",
|
|
131
|
+
abandonedMutation: "delete",
|
|
132
|
+
}),
|
|
81
133
|
);
|
|
82
134
|
assert.equal(row.settlement, "delete_failed");
|
|
83
135
|
});
|
|
84
136
|
|
|
85
137
|
it("marks the drafts row", () => {
|
|
86
138
|
const row = toImapDraftRowData(
|
|
87
|
-
baseThread({
|
|
139
|
+
baseThread({
|
|
140
|
+
status: "active",
|
|
141
|
+
syncStatus: "abandoned",
|
|
142
|
+
abandonedMutation: "delete",
|
|
143
|
+
}),
|
|
88
144
|
);
|
|
89
145
|
assert.equal(row.settlement, "delete_failed");
|
|
90
146
|
});
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reading-pane notice names the mutation that gave up and offers the way
|
|
3
|
+
* out for that one (#1229).
|
|
4
|
+
*
|
|
5
|
+
* A delete retries through the ordinary delete endpoint. A move cannot — the
|
|
6
|
+
* destination the give-up discarded is on no row — so its retry is the folder
|
|
7
|
+
* picker, which needs an account to scope its list to. The caller's `accountId`
|
|
8
|
+
* is absent on rows from per-mailbox endpoints and for as long as the
|
|
9
|
+
* conversation's own lookup is in flight, and a notice that names a failure and
|
|
10
|
+
* then offers nothing is the same dead end as one that offers the wrong thing.
|
|
11
|
+
*/
|
|
12
|
+
import assert from "node:assert/strict";
|
|
13
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
14
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
15
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
16
|
+
import { createElement } from "react";
|
|
17
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
18
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
19
|
+
import { ErrorBannerProvider } from "../ui/ErrorBannerProvider";
|
|
20
|
+
import { MessageSettlementAlert } from "./MessageSettlementAlert";
|
|
21
|
+
import { RoleAppointmentPromptProvider } from "./RoleAppointmentPromptProvider";
|
|
22
|
+
|
|
23
|
+
const ACCOUNT = "acc-1";
|
|
24
|
+
const MAILBOX = "mbx-inbox";
|
|
25
|
+
|
|
26
|
+
let harness: DomHarness | undefined;
|
|
27
|
+
let http: HttpMock;
|
|
28
|
+
|
|
29
|
+
const CONFIG = {
|
|
30
|
+
accounts: [{ accountId: ACCOUNT, email: "a@example.com" }],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const MAILBOXES = {
|
|
34
|
+
items: [
|
|
35
|
+
{
|
|
36
|
+
mailboxId: MAILBOX,
|
|
37
|
+
accountId: ACCOUNT,
|
|
38
|
+
fullPath: "INBOX",
|
|
39
|
+
hierarchyDelimiter: "/",
|
|
40
|
+
messageCount: 1,
|
|
41
|
+
},
|
|
42
|
+
],
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const respond = (path: string): unknown => {
|
|
46
|
+
if (path.endsWith("/config")) return CONFIG;
|
|
47
|
+
if (path.endsWith("/mailboxes")) return MAILBOXES;
|
|
48
|
+
return {};
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const thread = (
|
|
52
|
+
over: Partial<RemitImapThreadMessageResponse>,
|
|
53
|
+
): RemitImapThreadMessageResponse =>
|
|
54
|
+
({
|
|
55
|
+
threadMessageId: "tm-1",
|
|
56
|
+
threadId: "th-1",
|
|
57
|
+
messageId: "msg-1",
|
|
58
|
+
accountConfigId: "cfg-1",
|
|
59
|
+
mailboxId: MAILBOX,
|
|
60
|
+
sentDate: 0,
|
|
61
|
+
isRead: true,
|
|
62
|
+
hasAttachment: false,
|
|
63
|
+
hasStars: false,
|
|
64
|
+
star: "None",
|
|
65
|
+
isDeleted: false,
|
|
66
|
+
senderTrust: "unknown",
|
|
67
|
+
category: "uncategorized",
|
|
68
|
+
status: "active",
|
|
69
|
+
syncStatus: "abandoned",
|
|
70
|
+
abandonedMutation: "none",
|
|
71
|
+
createdAt: 0,
|
|
72
|
+
updatedAt: 0,
|
|
73
|
+
...over,
|
|
74
|
+
}) as RemitImapThreadMessageResponse;
|
|
75
|
+
|
|
76
|
+
const mount = (
|
|
77
|
+
threadMessage: RemitImapThreadMessageResponse,
|
|
78
|
+
accountId: string | undefined,
|
|
79
|
+
): void => {
|
|
80
|
+
harness = createDomHarness();
|
|
81
|
+
harness.render(
|
|
82
|
+
createElement(
|
|
83
|
+
QueryClientProvider,
|
|
84
|
+
{ client: harness.queryClient },
|
|
85
|
+
createElement(
|
|
86
|
+
ErrorBannerProvider,
|
|
87
|
+
null,
|
|
88
|
+
createElement(
|
|
89
|
+
RoleAppointmentPromptProvider,
|
|
90
|
+
null,
|
|
91
|
+
createElement(MessageSettlementAlert, { threadMessage, accountId }),
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
99
|
+
if (!harness) throw new Error("nothing mounted");
|
|
100
|
+
for (let round = 0; round < 40; round += 1) {
|
|
101
|
+
await harness.flush();
|
|
102
|
+
await harness.wait(0);
|
|
103
|
+
if (done()) return;
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const labels = (): string[] =>
|
|
108
|
+
(harness?.queryAll("button") ?? []).map(
|
|
109
|
+
(button) => button.getAttribute("aria-label") ?? button.textContent ?? "",
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
const notice = (): string => harness?.text() ?? "";
|
|
113
|
+
|
|
114
|
+
beforeEach(() => {
|
|
115
|
+
http = mockFetch((call) => respond(call.path));
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
afterEach(() => {
|
|
119
|
+
harness?.close();
|
|
120
|
+
harness = undefined;
|
|
121
|
+
http.restore();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
describe("MessageSettlementAlert", () => {
|
|
125
|
+
it("says nothing about a row no mutation gave up on", async () => {
|
|
126
|
+
mount(
|
|
127
|
+
thread({ syncStatus: "synced", abandonedMutation: "delete" }),
|
|
128
|
+
ACCOUNT,
|
|
129
|
+
);
|
|
130
|
+
await settle();
|
|
131
|
+
|
|
132
|
+
assert.equal(notice(), "");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("offers Delete again for a delete that gave up", async () => {
|
|
136
|
+
mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
|
|
137
|
+
await settle();
|
|
138
|
+
|
|
139
|
+
assert.match(notice(), /was not deleted/i);
|
|
140
|
+
assert.ok(labels().some((label) => /delete again/i.test(label)));
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* The account lookup fans out to `/config` and then to every account's
|
|
145
|
+
* mailbox list. Asking for it from each expanded message put that behind
|
|
146
|
+
* every open row in the product — and it did, until it was scoped to the one
|
|
147
|
+
* case that needs it. Pinned on the wire, because the cost is a request and
|
|
148
|
+
* only the request proves it is not being made.
|
|
149
|
+
*/
|
|
150
|
+
it("asks for no configuration at all unless a move gave up", async () => {
|
|
151
|
+
mount(thread({ abandonedMutation: "delete" }), ACCOUNT);
|
|
152
|
+
await settle();
|
|
153
|
+
|
|
154
|
+
assert.deepEqual(http.to("/config"), []);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("asks for none on a healthy row either", async () => {
|
|
158
|
+
mount(thread({ syncStatus: "synced" }), undefined);
|
|
159
|
+
await settle();
|
|
160
|
+
|
|
161
|
+
assert.deepEqual(http.to("/config"), []);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("offers a folder picker for a move that gave up, never Delete again", async () => {
|
|
165
|
+
mount(thread({ abandonedMutation: "move" }), ACCOUNT);
|
|
166
|
+
await settle(() => labels().some((label) => /move again/i.test(label)));
|
|
167
|
+
|
|
168
|
+
assert.match(notice(), /was not moved/i);
|
|
169
|
+
assert.ok(labels().some((label) => /move again/i.test(label)));
|
|
170
|
+
assert.ok(!labels().some((label) => /delete again/i.test(label)));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("resolves the account from the row's mailbox when the caller has none", async () => {
|
|
174
|
+
mount(thread({ abandonedMutation: "move" }), undefined);
|
|
175
|
+
await settle(() => labels().some((label) => /move again/i.test(label)));
|
|
176
|
+
|
|
177
|
+
assert.ok(
|
|
178
|
+
labels().some((label) => /move again/i.test(label)),
|
|
179
|
+
"a notice that names a failure always carries its way out",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -1,19 +1,34 @@
|
|
|
1
1
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { abandonedMutationOf } from "@remit/data-ports/message-settlement";
|
|
3
|
+
import { MessageMutation } from "@remit/domain-enums";
|
|
3
4
|
import { MessageSettlementNotice, messageSettlementCopy } from "@remit/ui";
|
|
4
5
|
import { useCallback, useMemo } from "react";
|
|
5
6
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
7
|
+
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
8
|
+
import { useMoveMessages } from "@/hooks/useMoveMessages";
|
|
6
9
|
import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
10
|
+
import { MoveToTrigger } from "./MoveToTrigger";
|
|
7
11
|
|
|
8
12
|
/**
|
|
9
|
-
* States, on the open message, that Remit
|
|
10
|
-
* the failure the list can only chip
|
|
13
|
+
* States, on the open message, that Remit gave up on a mutation (issue #1002) —
|
|
14
|
+
* the failure the list can only chip — and offers the way out for THAT
|
|
15
|
+
* mutation, never a different one (issue #1229).
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
17
|
+
* A delete retries through the ordinary delete endpoint: giving up put `status`
|
|
18
|
+
* back to `active`, so `settledPlacement` passes the row through and
|
|
14
19
|
* `POST /messages/delete` re-drives it against the folder the server actually
|
|
15
|
-
* holds it in.
|
|
16
|
-
*
|
|
20
|
+
* holds it in. A move cannot retry that way — the destination the give-up
|
|
21
|
+
* discarded is on no row — so its retry is the same folder picker every other
|
|
22
|
+
* move goes through, which asks for one. Every other give-up gets no treatment;
|
|
23
|
+
* `abandonedMutationOf` says which and why.
|
|
24
|
+
*
|
|
25
|
+
* The picker needs an account to scope its folder list to, and the caller's
|
|
26
|
+
* `accountId` is absent on every row that came from a per-mailbox endpoint and
|
|
27
|
+
* for as long as the conversation's own lookup is in flight. It is resolved
|
|
28
|
+
* from the row's mailbox here rather than left out, because a notice that names
|
|
29
|
+
* a failure and offers no way to undo it is the same defect as one that offers
|
|
30
|
+
* the wrong way (#1229) — the resolver is the one `MessageActionMenu` already
|
|
31
|
+
* uses for exactly this.
|
|
17
32
|
*/
|
|
18
33
|
export function MessageSettlementAlert({
|
|
19
34
|
threadMessage,
|
|
@@ -24,43 +39,86 @@ export function MessageSettlementAlert({
|
|
|
24
39
|
accountId: string | undefined;
|
|
25
40
|
className?: string;
|
|
26
41
|
}) {
|
|
27
|
-
const abandoned =
|
|
28
|
-
const
|
|
42
|
+
const abandoned = abandonedMutationOf(threadMessage);
|
|
43
|
+
const settlement =
|
|
44
|
+
abandoned === MessageMutation.delete
|
|
45
|
+
? ("delete_failed" as const)
|
|
46
|
+
: abandoned === MessageMutation.move
|
|
47
|
+
? ("move_failed" as const)
|
|
48
|
+
: undefined;
|
|
49
|
+
|
|
50
|
+
// Only a move that gave up, and only where the caller has no account of its
|
|
51
|
+
// own, has anything to resolve — so every other row subscribes to nothing.
|
|
52
|
+
// Asking unconditionally would pull `/config` and every account's mailbox
|
|
53
|
+
// list into each expanded message, for a case almost none of them are.
|
|
54
|
+
const { accountId: mailboxAccountId } = useMailboxAccount(
|
|
55
|
+
settlement === "move_failed" && !accountId
|
|
56
|
+
? threadMessage.mailboxId
|
|
57
|
+
: undefined,
|
|
58
|
+
);
|
|
59
|
+
const scopedAccountId = accountId ?? mailboxAccountId;
|
|
60
|
+
|
|
61
|
+
const { deleteMessages, isPending: isDeleting } = useDeleteMessages({
|
|
29
62
|
mailboxId: threadMessage.mailboxId,
|
|
30
63
|
threadId: threadMessage.threadId,
|
|
31
|
-
accountId,
|
|
64
|
+
accountId: scopedAccountId,
|
|
32
65
|
messages: [threadMessage],
|
|
33
66
|
});
|
|
67
|
+
const { moveMessages, isPending: isMoving } = useMoveMessages({
|
|
68
|
+
mailboxId: threadMessage.mailboxId,
|
|
69
|
+
threadId: threadMessage.threadId,
|
|
70
|
+
accountId: scopedAccountId,
|
|
71
|
+
});
|
|
34
72
|
|
|
35
|
-
const
|
|
73
|
+
const onDeleteAgain = useCallback(() => {
|
|
36
74
|
deleteMessages([threadMessage.messageId]);
|
|
37
75
|
}, [deleteMessages, threadMessage.messageId]);
|
|
38
76
|
|
|
77
|
+
const onMoveAgain = useCallback(
|
|
78
|
+
(destinationMailboxId: string) => {
|
|
79
|
+
moveMessages([threadMessage.messageId], destinationMailboxId);
|
|
80
|
+
},
|
|
81
|
+
[moveMessages, threadMessage.messageId],
|
|
82
|
+
);
|
|
83
|
+
|
|
39
84
|
const reportHref = useMemo(
|
|
40
85
|
() =>
|
|
41
|
-
|
|
86
|
+
settlement
|
|
42
87
|
? buildGitHubIssueUrl(
|
|
43
88
|
buildBugReportContext({
|
|
44
|
-
title: messageSettlementCopy.
|
|
45
|
-
errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus}`,
|
|
89
|
+
title: messageSettlementCopy[settlement].title,
|
|
90
|
+
errorMessage: `Message ${threadMessage.messageId}: status=${threadMessage.status} syncStatus=${threadMessage.syncStatus} abandonedMutation=${threadMessage.abandonedMutation}`,
|
|
46
91
|
}),
|
|
47
92
|
)
|
|
48
93
|
: undefined,
|
|
49
94
|
[
|
|
50
|
-
|
|
95
|
+
settlement,
|
|
51
96
|
threadMessage.messageId,
|
|
52
97
|
threadMessage.status,
|
|
53
98
|
threadMessage.syncStatus,
|
|
99
|
+
threadMessage.abandonedMutation,
|
|
54
100
|
],
|
|
55
101
|
);
|
|
56
102
|
|
|
57
|
-
if (!
|
|
103
|
+
if (!settlement) return null;
|
|
58
104
|
|
|
59
105
|
return (
|
|
60
106
|
<MessageSettlementNotice
|
|
61
|
-
settlement=
|
|
62
|
-
onRetry={
|
|
63
|
-
|
|
107
|
+
settlement={settlement}
|
|
108
|
+
onRetry={settlement === "delete_failed" ? onDeleteAgain : undefined}
|
|
109
|
+
action={
|
|
110
|
+
settlement === "move_failed" && scopedAccountId ? (
|
|
111
|
+
<MoveToTrigger
|
|
112
|
+
accountId={scopedAccountId}
|
|
113
|
+
currentMailboxId={threadMessage.mailboxId}
|
|
114
|
+
onMove={onMoveAgain}
|
|
115
|
+
disabled={isMoving}
|
|
116
|
+
variant="compact"
|
|
117
|
+
label={messageSettlementCopy.move_failed.retryLabel}
|
|
118
|
+
/>
|
|
119
|
+
) : undefined
|
|
120
|
+
}
|
|
121
|
+
retryPending={settlement === "delete_failed" ? isDeleting : isMoving}
|
|
64
122
|
reportHref={reportHref}
|
|
65
123
|
className={className}
|
|
66
124
|
/>
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from "@storybook/react-vite";
|
|
2
|
+
import { BlockedImagesNotice } from "@/components/mail/BlockedImagesNotice";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The bar above a message body whose remote images were not loaded.
|
|
6
|
+
*
|
|
7
|
+
* Two different facts wear the same strip. Images held back for privacy are a
|
|
8
|
+
* default the reader can lift, so the bar offers both ways out and neither
|
|
9
|
+
* control ever greys out. A blocked sender is an instruction the reader already
|
|
10
|
+
* gave — `BlockedFlag`: never load images, even on explicit click — so the bar
|
|
11
|
+
* carries no controls at all and says why instead, because an image-free
|
|
12
|
+
* message that explains nothing is indistinguishable from a broken one.
|
|
13
|
+
*/
|
|
14
|
+
const meta: Meta<typeof BlockedImagesNotice> = {
|
|
15
|
+
title: "Flows/Mail/Blocked Images Notice",
|
|
16
|
+
component: BlockedImagesNotice,
|
|
17
|
+
parameters: { layout: "padded" },
|
|
18
|
+
};
|
|
19
|
+
export default meta;
|
|
20
|
+
|
|
21
|
+
type Story = StoryObj<typeof BlockedImagesNotice>;
|
|
22
|
+
|
|
23
|
+
const noop = () => {};
|
|
24
|
+
|
|
25
|
+
const base = {
|
|
26
|
+
blockedImageCount: 3,
|
|
27
|
+
canAlwaysTrust: true,
|
|
28
|
+
isTrustPending: false,
|
|
29
|
+
isSenderBlocked: false,
|
|
30
|
+
onLoadOnce: noop,
|
|
31
|
+
onAlwaysTrust: noop,
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const Column = ({ children }: { children: React.ReactNode }) => (
|
|
35
|
+
<div className="w-full max-w-2xl">{children}</div>
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** The default: images held back, and two ways to let them in. */
|
|
39
|
+
export const Privacy: Story = {
|
|
40
|
+
render: () => (
|
|
41
|
+
<Column>
|
|
42
|
+
<BlockedImagesNotice {...base} />
|
|
43
|
+
</Column>
|
|
44
|
+
),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** The trust toggle is in flight. The button stays pressable and says so. */
|
|
48
|
+
export const Trusting: Story = {
|
|
49
|
+
render: () => (
|
|
50
|
+
<Column>
|
|
51
|
+
<BlockedImagesNotice {...base} isTrustPending />
|
|
52
|
+
</Column>
|
|
53
|
+
),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** No parseable From-address, so there is nobody to trust — only Load once. */
|
|
57
|
+
export const NoSenderToTrust: Story = {
|
|
58
|
+
render: () => (
|
|
59
|
+
<Column>
|
|
60
|
+
<BlockedImagesNotice {...base} canAlwaysTrust={false} />
|
|
61
|
+
</Column>
|
|
62
|
+
),
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** A blocked sender: no way in, and the reason stated rather than left blank. */
|
|
66
|
+
export const SenderBlocked: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<Column>
|
|
69
|
+
<BlockedImagesNotice {...base} isSenderBlocked />
|
|
70
|
+
</Column>
|
|
71
|
+
),
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Blocked outranks trusted — a sender carrying both is still blocked. */
|
|
75
|
+
export const SenderBlockedAndTrusted: Story = {
|
|
76
|
+
render: () => (
|
|
77
|
+
<Column>
|
|
78
|
+
<BlockedImagesNotice {...base} isSenderBlocked canAlwaysTrust />
|
|
79
|
+
</Column>
|
|
80
|
+
),
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/** One image, and the copy counts it as one. */
|
|
84
|
+
export const SingleImage: Story = {
|
|
85
|
+
render: () => (
|
|
86
|
+
<Column>
|
|
87
|
+
<BlockedImagesNotice {...base} blockedImageCount={1} />
|
|
88
|
+
</Column>
|
|
89
|
+
),
|
|
90
|
+
};
|
|
@@ -56,16 +56,6 @@ describe("placementRefusalBanner", () => {
|
|
|
56
56
|
assert.match(banner.detail ?? "", /try again in a moment/i);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
-
it("tells an abandoned move to resync, not to wait", () => {
|
|
60
|
-
const banner = placementRefusalBanner(
|
|
61
|
-
{ reason: "unverified", messageId: "msg-1" },
|
|
62
|
-
1,
|
|
63
|
-
"delete",
|
|
64
|
-
);
|
|
65
|
-
assert.match(banner.detail ?? "", /sync the folder/i);
|
|
66
|
-
assert.doesNotMatch(banner.detail ?? "", /try again in a moment/i);
|
|
67
|
-
});
|
|
68
|
-
|
|
69
59
|
it("counts the selection in the title", () => {
|
|
70
60
|
assert.match(
|
|
71
61
|
placementRefusalBanner(
|
|
@@ -90,22 +80,19 @@ describe("placementRefusalBanner", () => {
|
|
|
90
80
|
|
|
91
81
|
const detail =
|
|
92
82
|
placementRefusalBanner(
|
|
93
|
-
{ reason: "
|
|
83
|
+
{ reason: "in_flight", messageId: "msg-1" },
|
|
94
84
|
1,
|
|
95
85
|
"move",
|
|
96
86
|
).detail ?? "";
|
|
97
|
-
assert.match(detail, /move it again/i);
|
|
98
87
|
assert.doesNotMatch(detail, /delete/i);
|
|
99
88
|
});
|
|
100
89
|
|
|
101
90
|
it("never repeats the server's uuid at the user", () => {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
|
|
109
|
-
}
|
|
91
|
+
const banner = placementRefusalBanner(
|
|
92
|
+
{ reason: "in_flight", messageId: "msg-1" },
|
|
93
|
+
1,
|
|
94
|
+
"delete",
|
|
95
|
+
);
|
|
96
|
+
assert.doesNotMatch(`${banner.title} ${banner.detail}`, /msg-1/);
|
|
110
97
|
});
|
|
111
98
|
});
|