@remit/web-client 0.0.271 → 0.0.273

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.271",
3
+ "version": "0.0.273",
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": {
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * React-render smoke test for `BlockedImagesNotice` — the privacy "images
3
- * blocked" bar extracted from `MessageBody`. Pins the never-disable-buttons
4
- * rule (#943): the "Always trust" button stays active while the trust toggle
5
- * is in flight. It must NOT render `disabled`; the in-flight state is reflected
6
- * via `aria-busy` and a pending label instead, so the control stays focusable
7
- * and clickable (the handler no-ops the second click on the consumer side).
3
+ * blocked" bar extracted from `MessageBody`. Pins two rules.
4
+ *
5
+ * The never-disable-buttons rule (#943): the "Always trust" button stays active
6
+ * while the trust toggle is in flight. It must NOT render `disabled`; the
7
+ * in-flight state is reflected via `aria-busy` and a pending label instead, so
8
+ * the control stays focusable and clickable (the handler no-ops the second
9
+ * click on the consumer side).
10
+ *
11
+ * The blocked-sender rule (#352): `BlockedFlag` says images never load "even on
12
+ * explicit click", so for a blocked sender the load affordances are absent
13
+ * rather than disabled, and the bar states the reason so an image-free message
14
+ * is never silent about why.
8
15
  *
9
16
  * Uses `react-dom/server`'s `renderToString` so the assertion runs without
10
17
  * jsdom/happy-dom (no new deps), matching `MessageBodyErrorBanner.render.test`.
@@ -26,6 +33,7 @@ const render = (
26
33
  blockedImageCount: 3,
27
34
  canAlwaysTrust: true,
28
35
  isTrustPending: false,
36
+ isSenderBlocked: false,
29
37
  onLoadOnce: noop,
30
38
  onAlwaysTrust: noop,
31
39
  ...overrides,
@@ -63,3 +71,44 @@ describe("BlockedImagesNotice — never-disable the Always trust button (#943)",
63
71
  assert.match(html, /<button\b[^>]*>Load once<\/button>/);
64
72
  });
65
73
  });
74
+
75
+ describe("BlockedImagesNotice — a blocked sender gets no way in (#352)", () => {
76
+ it("renders no load affordance at all for a blocked sender", () => {
77
+ const html = render({ isSenderBlocked: true });
78
+ assert.doesNotMatch(
79
+ html,
80
+ /<button/,
81
+ "blocked means never load, even on explicit click — the buttons are absent",
82
+ );
83
+ });
84
+
85
+ it("states that the sender is blocked, so an image-free message is not silent", () => {
86
+ const html = render({ isSenderBlocked: true });
87
+ assert.match(html, /you blocked this sender/);
88
+ assert.match(html, /3 images hidden/);
89
+ });
90
+
91
+ it("offers nothing to disable — absence, not a dead control", () => {
92
+ const html = render({ isSenderBlocked: true });
93
+ assert.doesNotMatch(
94
+ html,
95
+ /<(?:button|a|input|select|textarea)\b|\brole="button"|\btabindex=/,
96
+ "a greyed-out control still reads as 'try again'; the bar carries no interactive element at all",
97
+ );
98
+ assert.doesNotMatch(html, /Load once/);
99
+ assert.doesNotMatch(html, /Always trust/);
100
+ });
101
+
102
+ it("stays blocked when the sender is somehow trusted as well", () => {
103
+ const html = render({ isSenderBlocked: true, canAlwaysTrust: true });
104
+ assert.doesNotMatch(html, /<button/);
105
+ assert.match(html, /you blocked this sender/);
106
+ });
107
+
108
+ it("singularizes the count", () => {
109
+ assert.match(
110
+ render({ isSenderBlocked: true, blockedImageCount: 1 }),
111
+ /1 image hidden/,
112
+ );
113
+ });
114
+ });
@@ -2,40 +2,62 @@ interface BlockedImagesNoticeProps {
2
2
  blockedImageCount: number;
3
3
  canAlwaysTrust: boolean;
4
4
  isTrustPending: boolean;
5
+ /**
6
+ * The From-address carries the `blocked` flag. Blocking is a stronger
7
+ * instruction than "not trusted": images never load, "even on explicit
8
+ * click", so the bar states why the message is image-free instead of
9
+ * offering a way past it. The affordances are absent, never disabled — a
10
+ * dead button reads as broken (#943).
11
+ */
12
+ isSenderBlocked: boolean;
5
13
  onLoadOnce: () => void;
6
14
  onAlwaysTrust: () => void;
7
15
  }
8
16
 
17
+ const noticeCopy = (
18
+ blockedImageCount: number,
19
+ isSenderBlocked: boolean,
20
+ ): string => {
21
+ const images = `${blockedImageCount} image${blockedImageCount > 1 ? "s" : ""}`;
22
+ if (isSenderBlocked) return `${images} hidden — you blocked this sender`;
23
+ return `${images} blocked for privacy`;
24
+ };
25
+
9
26
  export const BlockedImagesNotice = ({
10
27
  blockedImageCount,
11
28
  canAlwaysTrust,
12
29
  isTrustPending,
30
+ isSenderBlocked,
13
31
  onLoadOnce,
14
32
  onAlwaysTrust,
15
33
  }: BlockedImagesNoticeProps) => (
16
- <div className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm">
34
+ <div
35
+ data-testid="blocked-images-notice"
36
+ className="mb-3 flex items-center justify-between rounded-md bg-surface-sunken/50 px-3 py-2 text-sm"
37
+ >
17
38
  <span className="text-fg-muted">
18
- {blockedImageCount} image{blockedImageCount > 1 ? "s" : ""} blocked for
19
- privacy
39
+ {noticeCopy(blockedImageCount, isSenderBlocked)}
20
40
  </span>
21
- <div className="flex items-center gap-3">
22
- <button
23
- type="button"
24
- onClick={onLoadOnce}
25
- className="text-accent hover:underline"
26
- >
27
- Load once
28
- </button>
29
- {canAlwaysTrust && (
41
+ {!isSenderBlocked && (
42
+ <div className="flex items-center gap-3">
30
43
  <button
31
44
  type="button"
32
- onClick={onAlwaysTrust}
33
- aria-busy={isTrustPending}
34
- className="text-accent hover:underline aria-busy:opacity-50"
45
+ onClick={onLoadOnce}
46
+ className="text-accent hover:underline"
35
47
  >
36
- {isTrustPending ? "Trusting…" : "Always trust"}
48
+ Load once
37
49
  </button>
38
- )}
39
- </div>
50
+ {canAlwaysTrust && (
51
+ <button
52
+ type="button"
53
+ onClick={onAlwaysTrust}
54
+ aria-busy={isTrustPending}
55
+ className="text-accent hover:underline aria-busy:opacity-50"
56
+ >
57
+ {isTrustPending ? "Trusting…" : "Always trust"}
58
+ </button>
59
+ )}
60
+ </div>
61
+ )}
40
62
  </div>
41
63
  );
@@ -0,0 +1,331 @@
1
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { useQueryClient } from "@tanstack/react-query";
3
+ import {
4
+ createContext,
5
+ type ReactNode,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useRef,
11
+ useState,
12
+ } from "react";
13
+ import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
14
+ import { buildMutationErrorBanner } from "@/components/ui/error-banners";
15
+ import { isFolderRoleRefusal } from "@/components/ui/folder-role-refusal";
16
+ import {
17
+ bulkActionFailureDetail,
18
+ bulkActionFailureTitle,
19
+ bulkRunBusyRefusal,
20
+ } from "@/lib/bulk-action-copy";
21
+ import {
22
+ type ApplyBatch,
23
+ type BulkActionProgress,
24
+ type BulkActionTarget,
25
+ type BulkRunOutcome,
26
+ type BulkRunStart,
27
+ type EscalatedAction,
28
+ type FetchIdsPage,
29
+ honestProgress,
30
+ mailboxesTouchedBy,
31
+ runChunkedAction,
32
+ runPredicateAction,
33
+ } from "@/lib/bulk-actions";
34
+ import {
35
+ invalidateThreadListQueries,
36
+ threadListCacheKeys,
37
+ } from "@/lib/thread-list-cache";
38
+ import { useRoleAppointmentPrompt } from "./RoleAppointmentPromptProvider";
39
+
40
+ /**
41
+ * What the run pages, which is the one thing a bounded and an escalated run do
42
+ * differently: a ticked selection is a list of ids the surface already holds,
43
+ * an escalated one is a predicate the server re-resolves page by page.
44
+ */
45
+ export type BulkRunSource =
46
+ | { kind: "targets"; targets: readonly BulkActionTarget[] }
47
+ | { kind: "predicate"; fetchPage: FetchIdsPage };
48
+
49
+ export interface BulkRunRequest {
50
+ action: EscalatedAction;
51
+ /** The mailbox the run belongs to — where its progress shows on return. */
52
+ mailboxId: string;
53
+ /** That mailbox in the user's words, for a refusal that names this run. */
54
+ mailboxLabel?: string;
55
+ /** Owning account, for the unseen-count invalidation on completion. */
56
+ accountId: string | undefined;
57
+ /** The count the run was offered against, for the bar and for the ending. */
58
+ matched: number;
59
+ source: BulkRunSource;
60
+ /** Issues one batch of the action. Closed over the SDK, never over a screen. */
61
+ applyBatch: ApplyBatch;
62
+ /**
63
+ * States how the run ended, once no screen is left reporting on it in place.
64
+ * Called by the provider rather than by the caller, so an ending that lands
65
+ * after the surface unmounted is still said out loud (#112). Absent on a
66
+ * surface with nowhere to say it.
67
+ */
68
+ reportEnding?: (
69
+ kind: EscalatedAction["kind"],
70
+ matched: number,
71
+ outcome: BulkRunOutcome,
72
+ ) => void;
73
+ /**
74
+ * Handed the release for this run's ending, for a screen that is showing the
75
+ * ending itself. The claim is made inside `start`, for the run it starts, so
76
+ * a commit that starts no run holds nothing over the ending of one that is
77
+ * already going (#112). Released when that screen goes.
78
+ */
79
+ claimEnding?: (release: () => void) => void;
80
+ }
81
+
82
+ /** A run in flight, as any screen reads it. */
83
+ export interface ActiveBulkRun {
84
+ action: EscalatedAction;
85
+ mailboxId: string;
86
+ /** Seeded from the count the run was offered against, so the bar has a
87
+ * denominator before the first batch comes back. */
88
+ progress: BulkActionProgress;
89
+ }
90
+
91
+ interface BulkRunContextValue {
92
+ run: ActiveBulkRun | undefined;
93
+ /**
94
+ * Runs `request`, or refuses it because one is already going. There is one
95
+ * slot: a second run would page behind the first's bar and answer to the
96
+ * first's Stop, so it is refused with the run in flight named rather than
97
+ * queued or started alongside.
98
+ */
99
+ start: (request: BulkRunRequest) => Promise<BulkRunStart>;
100
+ /**
101
+ * Ends the run `mailboxId` owns: the request on the wire is aborted and
102
+ * nothing more leaves. A stop aimed at any other mailbox is not this run's,
103
+ * and reaches nothing.
104
+ */
105
+ stop: (mailboxId: string) => void;
106
+ }
107
+
108
+ const BulkRunContext = createContext<BulkRunContextValue | undefined>(
109
+ undefined,
110
+ );
111
+
112
+ /**
113
+ * The owner of a chunked bulk run (#112).
114
+ *
115
+ * A run is mail leaving the mailbox, and it outlives every screen that can show
116
+ * it: the wizard invites the user to close it mid-run, and nothing stops them
117
+ * navigating to another mailbox on top of that. Held in the surface that started
118
+ * it, the progress, the count and the cancellation all unmounted with the route,
119
+ * so returning showed nothing, Stop reached nothing, and an ending that landed
120
+ * after the move was dropped in silence.
121
+ *
122
+ * Mounted at the root beside the other app-lifetime providers, this holds the
123
+ * run instead: its progress, its `AbortController`, the caches it invalidates,
124
+ * the refusal it replays and the ending it states. A screen reads the run and
125
+ * offers Stop; it never owns either.
126
+ *
127
+ * In memory and per tab. A run is a sequence of requests this tab is making, so
128
+ * a reload ends it — persisting the reading would show a bar for a run nothing
129
+ * is driving.
130
+ */
131
+ export const BulkRunProvider = ({ children }: { children: ReactNode }) => {
132
+ const [run, setRun] = useState<ActiveBulkRun | undefined>(undefined);
133
+ // The signal every call of the live run is issued under, so a stop ends the
134
+ // request on the wire instead of waiting for it to come back (#113).
135
+ const controllerRef = useRef<AbortController | undefined>(undefined);
136
+ // Identifies the live run across the awaits below, so a run that ended after
137
+ // the slot was taken from it — by a sign-out — writes nothing back.
138
+ const liveRunRef = useRef<symbol | undefined>(undefined);
139
+ // What is in the slot, read synchronously: two commits landing in one frame
140
+ // both have to see the first of them.
141
+ const liveRequestRef = useRef<BulkRunRequest | undefined>(undefined);
142
+ // The runs a screen is reporting on in place, by token — never a count of
143
+ // claims over runs in general, which any commit could hold against any run.
144
+ const claimsRef = useRef(new Set<symbol>());
145
+ const queryClient = useQueryClient();
146
+ const { pushError } = useErrorBanners();
147
+ const { requestAppointment } = useRoleAppointmentPrompt();
148
+ // The run replays itself once a folder is appointed, so it needs a handle on
149
+ // itself that does not make `start` its own dependency.
150
+ const startRef = useRef<BulkRunContextValue["start"]>(async () => ({
151
+ kind: "ran",
152
+ outcome: { done: 0, failedIds: [], cancelled: false },
153
+ }));
154
+
155
+ /**
156
+ * The listings a run changed, and the unseen counts it moved. A cross-account
157
+ * selection has no single owning account, so the run's own targets name the
158
+ * accounts to refresh alongside the one the surface named.
159
+ */
160
+ const invalidateAfterRun = useCallback(
161
+ (request: BulkRunRequest) => {
162
+ invalidateThreadListQueries(
163
+ queryClient,
164
+ threadListCacheKeys(
165
+ mailboxesTouchedBy(request.action, request.mailboxId),
166
+ ),
167
+ );
168
+ const touched = new Set<string>();
169
+ if (request.accountId) touched.add(request.accountId);
170
+ if (request.source.kind === "targets") {
171
+ for (const target of request.source.targets) {
172
+ if (target.accountId) touched.add(target.accountId);
173
+ }
174
+ }
175
+ for (const accountId of touched) {
176
+ queryClient.invalidateQueries({
177
+ queryKey: mailboxOperationsListMailboxesQueryKey({
178
+ path: { accountId },
179
+ }),
180
+ });
181
+ }
182
+ },
183
+ [queryClient],
184
+ );
185
+
186
+ const start = useCallback(
187
+ async (request: BulkRunRequest): Promise<BulkRunStart> => {
188
+ const inFlight = liveRequestRef.current;
189
+ if (inFlight) {
190
+ return {
191
+ kind: "refused",
192
+ reason: bulkRunBusyRefusal(
193
+ inFlight.action.kind,
194
+ inFlight.matched,
195
+ inFlight.mailboxLabel,
196
+ ),
197
+ };
198
+ }
199
+ const token = Symbol("bulk-run");
200
+ const controller = new AbortController();
201
+ controllerRef.current = controller;
202
+ liveRunRef.current = token;
203
+ liveRequestRef.current = request;
204
+ if (request.claimEnding) {
205
+ claimsRef.current.add(token);
206
+ request.claimEnding(() => claimsRef.current.delete(token));
207
+ }
208
+ setRun({
209
+ action: request.action,
210
+ mailboxId: request.mailboxId,
211
+ progress: { done: 0, total: request.matched },
212
+ });
213
+ // `honestProgress` widens `total` if `done` overtakes it (#109) — the
214
+ // predicate can match more by the time the run pages it than the count
215
+ // saw, and the bar must never show more done than out of.
216
+ const onProgress = (next: BulkActionProgress) => {
217
+ if (liveRunRef.current !== token) return;
218
+ setRun((held) =>
219
+ held ? { ...held, progress: honestProgress(next) } : held,
220
+ );
221
+ };
222
+
223
+ const outcome =
224
+ request.source.kind === "targets"
225
+ ? await runChunkedAction(
226
+ request.source.targets,
227
+ request.applyBatch,
228
+ onProgress,
229
+ controller.signal,
230
+ )
231
+ : await runPredicateAction(
232
+ request.source.fetchPage,
233
+ request.matched,
234
+ request.applyBatch,
235
+ onProgress,
236
+ controller.signal,
237
+ );
238
+
239
+ const reportedInPlace = claimsRef.current.delete(token);
240
+ if (liveRunRef.current === token) {
241
+ liveRunRef.current = undefined;
242
+ liveRequestRef.current = undefined;
243
+ setRun(undefined);
244
+ }
245
+
246
+ // A provenance refusal is answered by the appointment prompt exactly as
247
+ // it is on the single-row path (#887, #876). Left to the banner it
248
+ // arrives as the API's own sentence — "Appoint one under Settings ›
249
+ // Folder roles" — which asks the user to reassemble a select-all they
250
+ // have already made.
251
+ const refusal = outcome.error
252
+ ? isFolderRoleRefusal(outcome.error)
253
+ : undefined;
254
+ if (refusal) {
255
+ requestAppointment({
256
+ accountId: refusal.accountId,
257
+ role: refusal.role,
258
+ reason: refusal.reason,
259
+ action: { kind: "delete", count: request.matched },
260
+ // The replay is a new run, and it claims nothing: the claim the
261
+ // original was started under belongs to a screen whose release has
262
+ // already fired or never will for this run, so re-claiming would
263
+ // leave the replay's ending held back by nobody and never said.
264
+ onAppointed: async () => {
265
+ const replayed = await startRef.current({
266
+ ...request,
267
+ claimEnding: undefined,
268
+ });
269
+ if (replayed.kind === "refused") {
270
+ pushError({
271
+ title: bulkActionFailureTitle(request.action.kind, 0),
272
+ detail: replayed.reason,
273
+ });
274
+ }
275
+ },
276
+ });
277
+ } else if (outcome.error) {
278
+ pushError(
279
+ buildMutationErrorBanner(
280
+ bulkActionFailureTitle(request.action.kind, outcome.done),
281
+ bulkActionFailureDetail(request.action.kind),
282
+ outcome.error,
283
+ ),
284
+ );
285
+ }
286
+ // Nobody is left showing this run, so the ending is said where the user
287
+ // now is. A screen still reporting on it says it in place instead.
288
+ if (!reportedInPlace) {
289
+ request.reportEnding?.(request.action.kind, request.matched, outcome);
290
+ }
291
+ if (outcome.done > 0) invalidateAfterRun(request);
292
+ return { kind: "ran", outcome };
293
+ },
294
+ [invalidateAfterRun, pushError, requestAppointment],
295
+ );
296
+ startRef.current = start;
297
+
298
+ const stop = useCallback((mailboxId: string) => {
299
+ if (liveRequestRef.current?.mailboxId !== mailboxId) return;
300
+ controllerRef.current?.abort();
301
+ }, []);
302
+
303
+ // A run belongs to the session that started it. Both auth shells gate the
304
+ // whole app on the session, so signing out or switching accounts takes this
305
+ // provider down with the router — and a run left paging past that would go on
306
+ // deleting mail for an account nobody is signed in to.
307
+ useEffect(() => {
308
+ const claims = claimsRef.current;
309
+ return () => {
310
+ controllerRef.current?.abort();
311
+ controllerRef.current = undefined;
312
+ liveRunRef.current = undefined;
313
+ liveRequestRef.current = undefined;
314
+ claims.clear();
315
+ };
316
+ }, []);
317
+
318
+ const value = useMemo(() => ({ run, start, stop }), [run, start, stop]);
319
+
320
+ return (
321
+ <BulkRunContext.Provider value={value}>{children}</BulkRunContext.Provider>
322
+ );
323
+ };
324
+
325
+ export const useBulkRun = (): BulkRunContextValue => {
326
+ const context = useContext(BulkRunContext);
327
+ if (!context) {
328
+ throw new Error("useBulkRun must be used within a BulkRunProvider");
329
+ }
330
+ return context;
331
+ };
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Image gating at the `MessageBody` seam (#352). `BlockedFlag` declares that a
3
+ * blocked sender's images never load "even on explicit click", and Settings >
4
+ * Senders promises the same. Blocked therefore suppresses auto-load AND removes
5
+ * the manual load affordances, and outranks `trusted` when both are set.
6
+ *
7
+ * Renders through the real pipeline — sanitizer, blocked-image count, notice —
8
+ * against the jsdom harness, so the assertions read the DOM the user gets.
9
+ */
10
+
11
+ import assert from "node:assert/strict";
12
+ import { afterEach, describe, it } from "node:test";
13
+ import { createElement } from "react";
14
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
15
+ import { MessageBody } from "./MessageBody";
16
+
17
+ let harness: DomHarness | undefined;
18
+
19
+ // The sandboxed frame measures itself once the iframe loads. jsdom fires that
20
+ // load but has no ResizeObserver, and the resulting ReferenceError is raised
21
+ // inside a listener where no assertion can see it.
22
+ (globalThis as { ResizeObserver?: unknown }).ResizeObserver = class {
23
+ observe() {}
24
+ unobserve() {}
25
+ disconnect() {}
26
+ };
27
+
28
+ afterEach(() => {
29
+ harness?.close();
30
+ harness = undefined;
31
+ });
32
+
33
+ const MARKETING_HTML = `
34
+ <div>
35
+ <img src="https://tracker.example/hero.png" alt="Hero" />
36
+ <p>Up to 40% off everything this weekend.</p>
37
+ <img src="https://tracker.example/pixel.gif" alt="" />
38
+ </div>
39
+ `;
40
+
41
+ // Every remote-image vector an email can reach for, not just `img src`.
42
+ const MULTI_VECTOR_HTML = `
43
+ <div>
44
+ <img src="https://tracker.example/hero.png" srcset="https://tracker.example/hero@2x.png 2x" alt="Hero" />
45
+ <picture>
46
+ <source srcset="https://tracker.example/wide.png" type="image/png" />
47
+ <img src="https://tracker.example/narrow.png" alt="Deal" />
48
+ </picture>
49
+ <video poster="https://tracker.example/poster.jpg"><source src="https://tracker.example/clip.mp4" type="video/mp4" /></video>
50
+ </div>
51
+ `;
52
+
53
+ const mount = (
54
+ flags: { isTrusted?: boolean; isBlocked?: boolean },
55
+ overrides: { html?: string; messageId?: string } = {},
56
+ ) => {
57
+ harness = createDomHarness();
58
+ harness.renderApp(
59
+ createElement(MessageBody, {
60
+ html: MARKETING_HTML,
61
+ messageId: "msg-1",
62
+ fromAddressId: "addr-1",
63
+ category: "marketing",
64
+ ...flags,
65
+ ...overrides,
66
+ }),
67
+ );
68
+ return harness;
69
+ };
70
+
71
+ const rerender = (
72
+ dom: DomHarness,
73
+ flags: { isTrusted?: boolean; isBlocked?: boolean },
74
+ overrides: { html?: string; messageId?: string } = {},
75
+ ) => {
76
+ dom.renderApp(
77
+ createElement(MessageBody, {
78
+ html: MARKETING_HTML,
79
+ messageId: "msg-1",
80
+ fromAddressId: "addr-1",
81
+ category: "marketing",
82
+ ...flags,
83
+ ...overrides,
84
+ }),
85
+ );
86
+ };
87
+
88
+ const frameHtml = (dom: DomHarness): string =>
89
+ dom.query<HTMLIFrameElement>("iframe")?.getAttribute("srcdoc") ?? "";
90
+
91
+ // A blocked image keeps its original URL in `data-blocked-src` — only a live
92
+ // `src=` means the browser will fetch it.
93
+ const REMOTE_SRC = /\ssrc="https:\/\/tracker\.example\/hero\.png"/;
94
+
95
+ describe("MessageBody image gating — blocked senders (#352)", () => {
96
+ it("never auto-loads images for a blocked sender", () => {
97
+ const dom = mount({ isBlocked: true });
98
+ assert.match(
99
+ frameHtml(dom),
100
+ /data-blocked-src/,
101
+ "a blocked sender's remote images must stay placeholders",
102
+ );
103
+ assert.doesNotMatch(frameHtml(dom), REMOTE_SRC);
104
+ });
105
+
106
+ it("offers no manual load affordance for a blocked sender", () => {
107
+ const dom = mount({ isBlocked: true });
108
+ assert.equal(
109
+ dom.queryAll("button").length,
110
+ 0,
111
+ "never load, even on explicit click — the controls are absent, not disabled",
112
+ );
113
+ assert.doesNotMatch(dom.html(), /\bdisabled\b/);
114
+ });
115
+
116
+ it("says why the message has no images instead of going quiet", () => {
117
+ const dom = mount({ isBlocked: true });
118
+ assert.match(dom.text(), /2 images hidden/);
119
+ assert.match(dom.text(), /you blocked this sender/);
120
+ });
121
+
122
+ it("keeps images blocked when the sender is both blocked and trusted", () => {
123
+ const dom = mount({ isBlocked: true, isTrusted: true });
124
+ assert.match(frameHtml(dom), /data-blocked-src/);
125
+ assert.equal(dom.queryAll("button").length, 0);
126
+ assert.match(dom.text(), /you blocked this sender/);
127
+ });
128
+
129
+ it("emits no fetchable tracker URL through srcset, picture or poster either", () => {
130
+ const dom = mount({ isBlocked: true }, { html: MULTI_VECTOR_HTML });
131
+ // The placeholder swap parks the original `img src` in
132
+ // `data-blocked-src` for "load once"; anywhere else in the srcdoc, a
133
+ // tracker URL is a request the frame will make.
134
+ const live = frameHtml(dom).replace(/data-blocked-src="[^"]*"/g, "");
135
+ assert.doesNotMatch(
136
+ live,
137
+ /tracker\.example/,
138
+ "blocked means no remote image request, whichever attribute names it",
139
+ );
140
+ assert.match(
141
+ frameHtml(dom),
142
+ /data-blocked-src="https:\/\/tracker\.example/,
143
+ );
144
+ });
145
+ });
146
+
147
+ describe("MessageBody load-once is scoped to one message (#352)", () => {
148
+ it("drops the grant when the same instance is handed a different message", () => {
149
+ const dom = mount({});
150
+ dom.click(dom.byText("button", "Load once"));
151
+ assert.match(frameHtml(dom), REMOTE_SRC);
152
+
153
+ rerender(dom, {}, { messageId: "msg-2" });
154
+
155
+ assert.doesNotMatch(
156
+ frameHtml(dom),
157
+ REMOTE_SRC,
158
+ "a load-once decision belongs to the message it was taken on",
159
+ );
160
+ assert.match(frameHtml(dom), /data-blocked-src/);
161
+ assert.ok(dom.byText("button", "Load once"));
162
+ });
163
+
164
+ it("keeps the grant while the message stays the same", () => {
165
+ const dom = mount({});
166
+ dom.click(dom.byText("button", "Load once"));
167
+ rerender(dom, { isTrusted: false });
168
+ assert.match(frameHtml(dom), REMOTE_SRC);
169
+ });
170
+ });
171
+
172
+ describe("MessageBody image gating — the states blocking must not change", () => {
173
+ it("auto-loads a trusted sender's images and shows no notice", () => {
174
+ const dom = mount({ isTrusted: true });
175
+ assert.match(frameHtml(dom), REMOTE_SRC);
176
+ assert.doesNotMatch(frameHtml(dom), /data-blocked-src/);
177
+ assert.doesNotMatch(dom.text(), /hidden|blocked for privacy/);
178
+ });
179
+
180
+ it("still offers Load once and Always trust to a merely untrusted sender", () => {
181
+ const dom = mount({});
182
+ assert.match(frameHtml(dom), /data-blocked-src/);
183
+ assert.match(dom.text(), /2 images blocked for privacy/);
184
+ assert.ok(dom.byText("button", "Load once"));
185
+ assert.ok(dom.byText("button", "Always trust"));
186
+ });
187
+
188
+ it("loads the images once the untrusted sender's Load once is taken", () => {
189
+ const dom = mount({});
190
+ dom.click(dom.byText("button", "Load once"));
191
+ assert.match(frameHtml(dom), REMOTE_SRC);
192
+ });
193
+ });