@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 +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 +331 -0
- 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 +239 -0
- package/src/components/mail/MessageCard.tsx +8 -1
- package/src/components/mail/MessageList.tsx +38 -23
- package/src/components/mail/SelectionWizardHost.tsx +57 -24
- package/src/components/mail/blocked-images-notice.stories.tsx +90 -0
- package/src/hooks/cross-account-bulk.render.test.ts +3 -3
- package/src/hooks/escalated-run-lifetime.render.test.ts +5 -4
- package/src/hooks/escalated-run-refusal.render.test.ts +219 -5
- package/src/hooks/escalated-run-survives-navigation.render.test.ts +524 -0
- package/src/hooks/useEscalatedActions.test.ts +1 -1
- package/src/hooks/useEscalatedActions.ts +93 -187
- package/src/lib/bulk-action-copy.ts +21 -0
- package/src/lib/bulk-actions.ts +32 -0
- package/src/routes/__root.tsx +16 -11
- package/src/test-support/bulk-run.ts +13 -0
- package/src/test-support/dom.ts +6 -1
|
@@ -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
|
+
};
|
|
@@ -20,11 +20,11 @@
|
|
|
20
20
|
import assert from "node:assert/strict";
|
|
21
21
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
22
22
|
import { act, createElement } from "react";
|
|
23
|
-
import type { BulkActionTarget } from "../lib/bulk-actions";
|
|
23
|
+
import type { BulkActionTarget, EscalatedAction } from "../lib/bulk-actions";
|
|
24
|
+
import { ranOutcome } from "../test-support/bulk-run";
|
|
24
25
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
25
26
|
import { type HttpMock, httpError, mockFetch } from "../test-support/http";
|
|
26
27
|
import {
|
|
27
|
-
type EscalatedAction,
|
|
28
28
|
type UseEscalatedActionsResult,
|
|
29
29
|
useEscalatedActions,
|
|
30
30
|
} from "./useEscalatedActions";
|
|
@@ -80,7 +80,7 @@ const run = async (
|
|
|
80
80
|
});
|
|
81
81
|
await settle();
|
|
82
82
|
if (!started) throw new Error("the run never started");
|
|
83
|
-
return started;
|
|
83
|
+
return ranOutcome(await started);
|
|
84
84
|
};
|
|
85
85
|
|
|
86
86
|
/** The message-id list of every bulk request that left, in order. */
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
import assert from "node:assert/strict";
|
|
15
15
|
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
16
16
|
import { act, createElement } from "react";
|
|
17
|
+
import { ranOutcome } from "../test-support/bulk-run";
|
|
17
18
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
18
19
|
import {
|
|
19
20
|
type EscalationSearchQuery,
|
|
@@ -222,7 +223,7 @@ describe("an escalated run the user walks away from", () => {
|
|
|
222
223
|
press(() => hook().clear());
|
|
223
224
|
press(() => server?.release());
|
|
224
225
|
await settle();
|
|
225
|
-
const outcome = await run;
|
|
226
|
+
const outcome = ranOutcome(await run);
|
|
226
227
|
|
|
227
228
|
assert.equal(outcome.cancelled, false);
|
|
228
229
|
assert.equal(outcome.done, TOTAL);
|
|
@@ -238,7 +239,7 @@ describe("an escalated run the user walks away from", () => {
|
|
|
238
239
|
setQuery({ query: "invoices" });
|
|
239
240
|
press(() => server?.release());
|
|
240
241
|
await settle();
|
|
241
|
-
const outcome = await run;
|
|
242
|
+
const outcome = ranOutcome(await run);
|
|
242
243
|
|
|
243
244
|
assert.equal(outcome.done, TOTAL);
|
|
244
245
|
assert.deepEqual(
|
|
@@ -280,7 +281,7 @@ describe("stopping a run with a delete already on the wire", () => {
|
|
|
280
281
|
press(() => hook().stop());
|
|
281
282
|
press(() => server?.release());
|
|
282
283
|
await settle();
|
|
283
|
-
const outcome = await run;
|
|
284
|
+
const outcome = ranOutcome(await run);
|
|
284
285
|
|
|
285
286
|
assert.equal(server?.aborted(), 1, "the delete in flight was cancelled");
|
|
286
287
|
assert.equal(
|
|
@@ -326,7 +327,7 @@ describe("stopping the run, which is a different press", () => {
|
|
|
326
327
|
press(() => hook().stop());
|
|
327
328
|
press(() => server?.release());
|
|
328
329
|
await settle();
|
|
329
|
-
const outcome = await run;
|
|
330
|
+
const outcome = ranOutcome(await run);
|
|
330
331
|
|
|
331
332
|
assert.equal(outcome.cancelled, true);
|
|
332
333
|
assert.ok(outcome.done > 0, "the batches already sent still count");
|
|
@@ -11,32 +11,60 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import assert from "node:assert/strict";
|
|
14
|
-
import { afterEach, describe, it } from "node:test";
|
|
14
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
15
15
|
import { QueryClientProvider } from "@tanstack/react-query";
|
|
16
|
-
import { act, createElement } from "react";
|
|
16
|
+
import { act, createElement, type ReactNode, useEffect, useRef } from "react";
|
|
17
|
+
import { BulkRunProvider } from "@/components/mail/BulkRunProvider";
|
|
17
18
|
import { RoleAppointmentPromptProvider } from "@/components/mail/RoleAppointmentPromptProvider";
|
|
18
19
|
import { ErrorBannerProvider } from "@/components/ui/ErrorBannerProvider";
|
|
20
|
+
import type { BulkRunOutcome, EscalatedAction } from "../lib/bulk-actions";
|
|
19
21
|
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
20
|
-
import { type HttpMock, mockFetch } from "../test-support/http";
|
|
22
|
+
import { type HttpCall, type HttpMock, mockFetch } from "../test-support/http";
|
|
21
23
|
import {
|
|
22
24
|
type UseEscalatedActionsResult,
|
|
23
25
|
useEscalatedActions,
|
|
24
26
|
} from "./useEscalatedActions";
|
|
25
27
|
|
|
26
28
|
const INBOX = "mbx-inbox";
|
|
29
|
+
const ELSEWHERE = "mbx-archive";
|
|
27
30
|
const ACCOUNT = "acc-1";
|
|
28
31
|
const TRASH = "mbx-trash";
|
|
32
|
+
/** The one id whose delete is never answered, so a run holds the slot. */
|
|
33
|
+
const HELD = "msg-held";
|
|
34
|
+
const CONFIRM = "Set as Trash and delete";
|
|
35
|
+
const PICK_TRASH = "Set Prullenbak, 3 messages, as Trash";
|
|
29
36
|
|
|
30
37
|
let harness: DomHarness | undefined;
|
|
31
38
|
let http: HttpMock | undefined;
|
|
32
39
|
let hook: UseEscalatedActionsResult | undefined;
|
|
33
40
|
|
|
41
|
+
/** Endings the run's owner stated once no screen was left to state them. */
|
|
42
|
+
let endings: Array<{
|
|
43
|
+
kind: EscalatedAction["kind"];
|
|
44
|
+
matched: number;
|
|
45
|
+
outcome: BulkRunOutcome;
|
|
46
|
+
}> = [];
|
|
47
|
+
|
|
48
|
+
interface MountedScreen {
|
|
49
|
+
hook: UseEscalatedActionsResult;
|
|
50
|
+
/** Passed to the run this screen starts, the way the wizard's does. */
|
|
51
|
+
claimEnding: (release: () => void) => void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const screens = new Map<string, MountedScreen>();
|
|
55
|
+
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
endings = [];
|
|
58
|
+
screens.clear();
|
|
59
|
+
});
|
|
60
|
+
|
|
34
61
|
afterEach(() => {
|
|
35
62
|
harness?.close();
|
|
36
63
|
harness = undefined;
|
|
37
64
|
http?.restore();
|
|
38
65
|
http = undefined;
|
|
39
66
|
hook = undefined;
|
|
67
|
+
screens.clear();
|
|
40
68
|
});
|
|
41
69
|
|
|
42
70
|
const CONFIG = {
|
|
@@ -74,11 +102,12 @@ const Probe = () => {
|
|
|
74
102
|
return null;
|
|
75
103
|
};
|
|
76
104
|
|
|
77
|
-
const settle = async (): Promise<void> => {
|
|
105
|
+
const settle = async (done: () => boolean = () => false): Promise<void> => {
|
|
78
106
|
if (!harness) throw new Error("nothing mounted");
|
|
79
107
|
for (let round = 0; round < 40; round += 1) {
|
|
80
108
|
await harness.flush();
|
|
81
109
|
await harness.wait(0);
|
|
110
|
+
if (done()) return;
|
|
82
111
|
}
|
|
83
112
|
};
|
|
84
113
|
|
|
@@ -95,7 +124,7 @@ const mount = async (respond: (path: string) => unknown): Promise<void> => {
|
|
|
95
124
|
createElement(
|
|
96
125
|
RoleAppointmentPromptProvider,
|
|
97
126
|
null,
|
|
98
|
-
createElement(Probe),
|
|
127
|
+
createElement(BulkRunProvider, null, createElement(Probe)),
|
|
99
128
|
),
|
|
100
129
|
),
|
|
101
130
|
),
|
|
@@ -154,3 +183,188 @@ describe("a bulk delete refused for its folder role", () => {
|
|
|
154
183
|
assert.doesNotMatch(text, /Confirm this account's Trash folder/);
|
|
155
184
|
});
|
|
156
185
|
});
|
|
186
|
+
|
|
187
|
+
const MAILBOXES = {
|
|
188
|
+
items: [
|
|
189
|
+
{
|
|
190
|
+
mailboxId: INBOX,
|
|
191
|
+
accountId: ACCOUNT,
|
|
192
|
+
fullPath: "INBOX",
|
|
193
|
+
hierarchyDelimiter: "/",
|
|
194
|
+
messageCount: 12,
|
|
195
|
+
},
|
|
196
|
+
{
|
|
197
|
+
mailboxId: TRASH,
|
|
198
|
+
accountId: ACCOUNT,
|
|
199
|
+
fullPath: "Prullenbak",
|
|
200
|
+
hierarchyDelimiter: "/",
|
|
201
|
+
messageCount: 3,
|
|
202
|
+
},
|
|
203
|
+
],
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A mailbox screen that states a run's ending in place while it is up, and
|
|
208
|
+
* releases that claim when it goes — the wizard's own arrangement.
|
|
209
|
+
*/
|
|
210
|
+
const screenFor = (mailboxId: string): ReactNode => {
|
|
211
|
+
const Screen = () => {
|
|
212
|
+
const release = useRef<(() => void) | undefined>(undefined);
|
|
213
|
+
useEffect(
|
|
214
|
+
() => () => {
|
|
215
|
+
release.current?.();
|
|
216
|
+
release.current = undefined;
|
|
217
|
+
},
|
|
218
|
+
[],
|
|
219
|
+
);
|
|
220
|
+
const live = useEscalatedActions({
|
|
221
|
+
mailboxId,
|
|
222
|
+
accountId: ACCOUNT,
|
|
223
|
+
enabled: true,
|
|
224
|
+
predicateKey: `${mailboxId}|npm`,
|
|
225
|
+
searchQuery: { query: "npm" },
|
|
226
|
+
reportEnding: (kind, matched, outcome) => {
|
|
227
|
+
endings.push({ kind, matched, outcome });
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
screens.set(mailboxId, {
|
|
231
|
+
hook: live,
|
|
232
|
+
claimEnding: (next) => {
|
|
233
|
+
release.current?.();
|
|
234
|
+
release.current = next;
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
return null;
|
|
238
|
+
};
|
|
239
|
+
return createElement(Screen, { key: mailboxId });
|
|
240
|
+
};
|
|
241
|
+
|
|
242
|
+
const screen = (mailboxId: string): MountedScreen => {
|
|
243
|
+
const mounted = screens.get(mailboxId);
|
|
244
|
+
if (!mounted) throw new Error(`no screen is mounted for ${mailboxId}`);
|
|
245
|
+
return mounted;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const mountApp = async (
|
|
249
|
+
respond: (call: HttpCall) => unknown,
|
|
250
|
+
element: ReactNode,
|
|
251
|
+
): Promise<void> => {
|
|
252
|
+
http = mockFetch(respond);
|
|
253
|
+
harness = createDomHarness();
|
|
254
|
+
harness.renderApp(element);
|
|
255
|
+
await settle();
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const deleteOne = (mailboxId: string, id: string, claimed: boolean): void => {
|
|
259
|
+
const mounted = screen(mailboxId);
|
|
260
|
+
void mounted.hook.runAction(
|
|
261
|
+
{ kind: "delete" },
|
|
262
|
+
[{ id, accountId: ACCOUNT }],
|
|
263
|
+
claimed ? mounted.claimEnding : undefined,
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
const promptIsUp = (): boolean =>
|
|
268
|
+
(harness?.text() ?? "").includes("Confirm this account's Trash folder");
|
|
269
|
+
|
|
270
|
+
/** The confirm mounts only once the account's folders have arrived. */
|
|
271
|
+
const confirmIsUp = (): boolean =>
|
|
272
|
+
(harness?.queryAll("button") ?? []).some((button) =>
|
|
273
|
+
(button.textContent ?? "").includes(CONFIRM),
|
|
274
|
+
);
|
|
275
|
+
|
|
276
|
+
/** Pick the account's real Trash in the picker, then press the confirm. */
|
|
277
|
+
const pressConfirm = async (): Promise<void> => {
|
|
278
|
+
await settle(() => !!harness?.query(`[aria-label="${PICK_TRASH}"]`));
|
|
279
|
+
if (!harness) throw new Error("nothing mounted");
|
|
280
|
+
harness.click(harness.byLabel(PICK_TRASH));
|
|
281
|
+
await settle(confirmIsUp);
|
|
282
|
+
harness.click(harness.byText("button", CONFIRM));
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The replay is a new run and claims nothing (#112). The claim the refused run
|
|
287
|
+
* was started under belongs to the screen that started it, and that screen has
|
|
288
|
+
* usually gone by the time the folder is appointed — its release has already
|
|
289
|
+
* fired, so a replay re-claiming under it would be held back by nobody and end
|
|
290
|
+
* in silence.
|
|
291
|
+
*/
|
|
292
|
+
describe("the run replayed once the folder is appointed", () => {
|
|
293
|
+
it("states its ending, once, with the screen that started it gone", async () => {
|
|
294
|
+
let stillRefusing = true;
|
|
295
|
+
await mountApp((call) => {
|
|
296
|
+
if (call.path.endsWith("/config")) return CONFIG;
|
|
297
|
+
if (call.path.endsWith("/mailboxes")) return MAILBOXES;
|
|
298
|
+
if (call.path.endsWith("/messages/delete")) {
|
|
299
|
+
if (!stillRefusing) return { successCount: 1, failureCount: 0 };
|
|
300
|
+
stillRefusing = false;
|
|
301
|
+
return refusal();
|
|
302
|
+
}
|
|
303
|
+
return {};
|
|
304
|
+
}, screenFor(INBOX));
|
|
305
|
+
|
|
306
|
+
await act(async () => {
|
|
307
|
+
deleteOne(INBOX, "msg-1", true);
|
|
308
|
+
});
|
|
309
|
+
await settle(promptIsUp);
|
|
310
|
+
assert.ok(promptIsUp(), "the refusal opened the appointment prompt");
|
|
311
|
+
|
|
312
|
+
// The user closes the wizard and leaves the mailbox while the prompt is up.
|
|
313
|
+
// The prompt and the run's owner are above the route, so both stay.
|
|
314
|
+
harness?.renderApp(createElement("div", null, "somewhere else"));
|
|
315
|
+
assert.equal(endings.length, 0, "nothing has ended yet");
|
|
316
|
+
|
|
317
|
+
await pressConfirm();
|
|
318
|
+
await settle(() => endings.length > 0);
|
|
319
|
+
|
|
320
|
+
assert.equal(endings.length, 1, "the replay's ending is said exactly once");
|
|
321
|
+
assert.equal(endings[0]?.kind, "delete");
|
|
322
|
+
assert.equal(endings[0]?.outcome.done, 1);
|
|
323
|
+
assert.equal(endings[0]?.outcome.error, undefined);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it("banners a replay refused because another run holds the slot", async () => {
|
|
327
|
+
let stillRefusing = true;
|
|
328
|
+
await mountApp(
|
|
329
|
+
(call) => {
|
|
330
|
+
if (call.path.endsWith("/config")) return CONFIG;
|
|
331
|
+
if (call.path.endsWith("/mailboxes")) return MAILBOXES;
|
|
332
|
+
if (call.path.endsWith("/messages/delete")) {
|
|
333
|
+
const ids =
|
|
334
|
+
(call.body as { messageIds?: string[] } | undefined)?.messageIds ??
|
|
335
|
+
[];
|
|
336
|
+
if (ids.includes(HELD)) return new Promise<never>(() => {});
|
|
337
|
+
if (stillRefusing) {
|
|
338
|
+
stillRefusing = false;
|
|
339
|
+
return refusal();
|
|
340
|
+
}
|
|
341
|
+
return { successCount: 1, failureCount: 0 };
|
|
342
|
+
}
|
|
343
|
+
return {};
|
|
344
|
+
},
|
|
345
|
+
createElement("div", null, screenFor(INBOX), screenFor(ELSEWHERE)),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await act(async () => {
|
|
349
|
+
deleteOne(INBOX, "msg-1", false);
|
|
350
|
+
});
|
|
351
|
+
await settle(promptIsUp);
|
|
352
|
+
|
|
353
|
+
// Another mailbox takes the one run slot while the prompt is still open.
|
|
354
|
+
await act(async () => {
|
|
355
|
+
deleteOne(ELSEWHERE, HELD, false);
|
|
356
|
+
});
|
|
357
|
+
await settle(() => screen(ELSEWHERE).hook.isRunning);
|
|
358
|
+
|
|
359
|
+
await pressConfirm();
|
|
360
|
+
await settle(() => (harness?.text() ?? "").includes("still running"));
|
|
361
|
+
|
|
362
|
+
const text = harness?.text() ?? "";
|
|
363
|
+
assert.match(text, /Couldn't delete these messages/);
|
|
364
|
+
assert.match(
|
|
365
|
+
text,
|
|
366
|
+
/still running/,
|
|
367
|
+
"a replay with nowhere to run says so instead of vanishing",
|
|
368
|
+
);
|
|
369
|
+
});
|
|
370
|
+
});
|