@remit/web-client 0.0.52 → 0.0.54

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.52",
3
+ "version": "0.0.54",
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": {
@@ -29,7 +29,7 @@
29
29
  "build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
30
30
  "preview": "vite preview",
31
31
  "test:typecheck": "npm run generate:routes && tsgo --noEmit",
32
- "test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-lines=70 --test 'src/**/*.test.ts'",
32
+ "test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
33
33
  "test": "npm run test:typecheck && npm run test:run"
34
34
  },
35
35
  "peerDependencies": {
@@ -0,0 +1,52 @@
1
+ import assert from "node:assert/strict";
2
+ import { readFileSync } from "node:fs";
3
+ import { dirname, resolve } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * The phone brief must mount the same selection surface the multi-pane list
9
+ * does (#203). `BriefPane` wires the bulk verbs and the selection bar into
10
+ * `DailyBrief` from the `BriefList` slot; the phone view used to render
11
+ * `DailyBrief` directly, without `onDeleteMessages`, so a selection made in the
12
+ * brief on a phone raised no action bar — selectable, but nothing to act with.
13
+ *
14
+ * As with `MessageList.selection.test.ts`, `BriefPane` weaves routing and
15
+ * several data hooks together, so the rule is enforced by reading the source:
16
+ * the phone view reuses the list slot rather than a bare `DailyBrief`.
17
+ */
18
+
19
+ const here = dirname(fileURLToPath(import.meta.url));
20
+ const source = readFileSync(resolve(here, "BriefPane.tsx"), "utf8");
21
+
22
+ const briefPhoneBody = (): string => {
23
+ const start = source.indexOf("function BriefPhone(");
24
+ assert.notEqual(start, -1, "BriefPhone is defined");
25
+ const next = source.indexOf("\nfunction ", start + 1);
26
+ return source.slice(start, next === -1 ? undefined : next);
27
+ };
28
+
29
+ describe("BriefPane phone selection surface (#203)", () => {
30
+ it("the phone brief reuses the list slot, not a bare DailyBrief", () => {
31
+ const body = briefPhoneBody();
32
+ assert.match(
33
+ body,
34
+ /<BriefList \/>/,
35
+ "phone brief renders the list slot that wires the bulk verbs",
36
+ );
37
+ assert.doesNotMatch(
38
+ body,
39
+ /<DailyBrief\b/,
40
+ "phone brief must not render DailyBrief directly — that drops onDeleteMessages and the selection bar with it",
41
+ );
42
+ });
43
+
44
+ it("the list slot hands DailyBrief the delete verb the selection bar gates on", () => {
45
+ const start = source.indexOf("function BriefList(");
46
+ assert.notEqual(start, -1, "BriefList is defined");
47
+ const next = source.indexOf("\nfunction ", start + 1);
48
+ const body = source.slice(start, next === -1 ? undefined : next);
49
+ assert.match(body, /onDeleteMessages=\{onDeleteMessages\}/);
50
+ assert.match(body, /onMarkMessagesRead=\{onMarkMessagesRead\}/);
51
+ });
52
+ });
@@ -381,14 +381,12 @@ function BriefPhone() {
381
381
  const {
382
382
  selectedThread,
383
383
  conversation,
384
- selectedMessageId,
385
384
  onSelectMessage,
386
- onSelectSearchResult,
387
385
  onCloseThread,
388
386
  nextMessageId,
389
387
  previousMessageId,
390
388
  } = useBriefPane();
391
- const { accounts, intelligenceOpen, onToggleIntelligence } = useMailContext();
389
+ const { intelligenceOpen, onToggleIntelligence } = useMailContext();
392
390
 
393
391
  if (conversation) {
394
392
  return (
@@ -427,14 +425,14 @@ function BriefPhone() {
427
425
  );
428
426
  }
429
427
 
428
+ // The same list slot the multi-pane layout mounts, so the phone brief wires
429
+ // the bulk verbs and the selection bar through `DailyBrief` exactly as the
430
+ // desktop list does. Rendering `DailyBrief` here directly dropped
431
+ // `onDeleteMessages`, so a phone selection raised no action bar (#203) — the
432
+ // same reuse `FlaggedPhone` already relies on.
430
433
  return (
431
434
  <div className="h-full">
432
- <DailyBrief
433
- accounts={accounts}
434
- selectedMessageId={selectedMessageId}
435
- onSelectMessage={onSelectMessage}
436
- onSelectSearchResult={onSelectSearchResult}
437
- />
435
+ <BriefList />
438
436
  </div>
439
437
  );
440
438
  }
@@ -91,3 +91,27 @@ describe("MessageList escalated actions", () => {
91
91
  );
92
92
  });
93
93
  });
94
+
95
+ /**
96
+ * A bounded confirm-delete used to open the surviving neighbour by writing
97
+ * `selectedMessageId` into the URL. On desktop that fills the reading pane
98
+ * beside the list; on a single-pane mobile layout the same navigation replaced
99
+ * the list with a full-screen message, so a bulk delete read as "jumped into a
100
+ * random message" rather than "the rows are gone" (#202). Mobile now stays on
101
+ * the list and raises the same completion banner a chunked run does.
102
+ */
103
+ describe("MessageList bounded delete stays on the list on mobile (#202)", () => {
104
+ it("only the desktop two-pane opens the surviving neighbour after a delete", () => {
105
+ assert.match(
106
+ source,
107
+ /if \(isDesktop\) \{\s*navigate\(\{\s*to: "\/mail\/\$mailboxId",\s*params: \{ mailboxId \},\s*search: \(prev\) => \(\{ \.\.\.prev, selectedMessageId: nextFocus \}\),\s*replace: true,\s*\}\);\s*\}/,
108
+ );
109
+ });
110
+
111
+ it("raises a completion banner on mobile so the delete is not silent", () => {
112
+ assert.match(
113
+ source,
114
+ /if \(!isDesktop\) \{\s*setCompletionBanner\(\s*bulkActionCompletionText\("delete", ids\.length\),?\s*\);\s*\}/,
115
+ );
116
+ });
117
+ });
@@ -662,12 +662,26 @@ export const MessageList = ({
662
662
  pendingDomFocusRef.current = nextFocus;
663
663
  cursorMovedByPointerRef.current = false;
664
664
  setFocusedMessageId(nextFocus);
665
- navigate({
666
- to: "/mail/$mailboxId",
667
- params: { mailboxId },
668
- search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
669
- replace: true,
670
- });
665
+ // Desktop is two-pane: opening the neighbour fills the reading pane
666
+ // beside the list. On a single-pane mobile layout the same navigation
667
+ // replaces the list with a full-screen message, so a bulk delete looks
668
+ // like it opened a random neighbour instead of removing the rows (#202).
669
+ // Mobile keeps the cursor move but stays on the list.
670
+ if (isDesktop) {
671
+ navigate({
672
+ to: "/mail/$mailboxId",
673
+ params: { mailboxId },
674
+ search: (prev) => ({ ...prev, selectedMessageId: nextFocus }),
675
+ replace: true,
676
+ });
677
+ }
678
+ }
679
+
680
+ // Mobile keeps the list up, so it needs its own signal the delete landed —
681
+ // the transient completion banner a chunked run already raises (#202). On
682
+ // desktop the rows leaving the list beside the reading pane is signal enough.
683
+ if (!isDesktop) {
684
+ setCompletionBanner(bulkActionCompletionText("delete", ids.length));
671
685
  }
672
686
  }, [
673
687
  pendingDelete,
@@ -676,6 +690,7 @@ export const MessageList = ({
676
690
  exitSelection,
677
691
  navigate,
678
692
  mailboxId,
693
+ isDesktop,
679
694
  runChunkedConfirmDelete,
680
695
  ]);
681
696
 
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The move-to-folder picker. It only fetches folders once it is opened, it
3
+ * marks the folder the messages are already in rather than offering it as a
4
+ * destination, and on desktop Escape or a click outside puts it away.
5
+ */
6
+
7
+ import assert from "node:assert/strict";
8
+ import { afterEach, describe, it } from "node:test";
9
+ import { mailboxOperationsListMailboxesQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
10
+ import type { RemitImapMailboxResponse } from "@remit/api-http-client/types.gen.ts";
11
+ import { createElement } from "react";
12
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
13
+ import { makeMailbox } from "../../test-support/fixtures";
14
+ import { MoveToTrigger } from "./MoveToTrigger";
15
+
16
+ let harness: DomHarness | undefined;
17
+
18
+ afterEach(() => {
19
+ harness?.close();
20
+ harness = undefined;
21
+ });
22
+
23
+ const ACCOUNT_ID = "acc-1";
24
+
25
+ const mount = (
26
+ options: {
27
+ mailboxes?: RemitImapMailboxResponse[];
28
+ currentMailboxId?: string;
29
+ disabledHint?: string;
30
+ onMove?: (destinationMailboxId: string) => void;
31
+ viewportWidth?: number;
32
+ } = {},
33
+ ): DomHarness => {
34
+ harness = createDomHarness({ viewportWidth: options.viewportWidth });
35
+ if (options.mailboxes) {
36
+ harness.queryClient.setQueryData(
37
+ mailboxOperationsListMailboxesQueryKey({
38
+ path: { accountId: ACCOUNT_ID },
39
+ }),
40
+ { items: options.mailboxes },
41
+ );
42
+ }
43
+ harness.renderApp(
44
+ createElement(MoveToTrigger, {
45
+ accountId: ACCOUNT_ID,
46
+ currentMailboxId: options.currentMailboxId ?? "mbx-inbox",
47
+ onMove: options.onMove ?? (() => undefined),
48
+ disabledHint: options.disabledHint,
49
+ }),
50
+ );
51
+ return harness;
52
+ };
53
+
54
+ const FOLDERS = [
55
+ makeMailbox({ mailboxId: "mbx-inbox", fullPath: "INBOX" }),
56
+ makeMailbox({ mailboxId: "mbx-work", fullPath: "Work" }),
57
+ makeMailbox({ mailboxId: "mbx-receipts", fullPath: "Receipts" }),
58
+ ];
59
+
60
+ describe("MoveToTrigger", () => {
61
+ it("reports itself as collapsed until it is opened", () => {
62
+ const dom = mount({ mailboxes: FOLDERS });
63
+ const trigger = dom.byLabel("Move to folder");
64
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
65
+ assert.equal(trigger.getAttribute("aria-controls"), null);
66
+ assert.equal(dom.query('[role="listbox"], input'), null);
67
+ });
68
+
69
+ it("opens a listbox of destinations and marks the folder we are in", () => {
70
+ const dom = mount({ mailboxes: FOLDERS });
71
+ dom.click(dom.byLabel("Move to folder"));
72
+
73
+ assert.equal(
74
+ dom.byLabel("Move to folder").getAttribute("aria-expanded"),
75
+ "true",
76
+ );
77
+ const labels = dom
78
+ .queryAll("[role=option]")
79
+ .map((option) => option.textContent ?? "");
80
+ assert.ok(labels.some((label) => label.includes("Work")));
81
+ assert.ok(labels.some((label) => label.includes("Receipts")));
82
+
83
+ // The folder the messages are already in stays in the list, marked as
84
+ // where they are now rather than presented as somewhere to move them.
85
+ const current = labels.find((label) => label.includes("current"));
86
+ assert.ok(current, "the source folder is marked as the current one");
87
+ });
88
+
89
+ it("moves to the folder the user picks and closes itself", () => {
90
+ const moved: string[] = [];
91
+ const dom = mount({
92
+ mailboxes: FOLDERS,
93
+ onMove: (id) => moved.push(id),
94
+ });
95
+ dom.click(dom.byLabel("Move to folder"));
96
+ dom.click(dom.byText("[role=option]", "Work"));
97
+
98
+ assert.deepEqual(moved, ["mbx-work"]);
99
+ assert.equal(
100
+ dom.byLabel("Move to folder").getAttribute("aria-expanded"),
101
+ "false",
102
+ );
103
+ });
104
+
105
+ it("closes on Escape and on a click outside it", () => {
106
+ const dom = mount({ mailboxes: FOLDERS });
107
+ const isOpen = () =>
108
+ dom.byLabel("Move to folder").getAttribute("aria-expanded") === "true";
109
+
110
+ dom.click(dom.byLabel("Move to folder"));
111
+ assert.equal(isOpen(), true);
112
+ dom.dispatch(
113
+ dom.window.document,
114
+ new dom.window.KeyboardEvent("keydown", { key: "Escape" }),
115
+ );
116
+ assert.equal(isOpen(), false);
117
+
118
+ dom.click(dom.byLabel("Move to folder"));
119
+ assert.equal(isOpen(), true);
120
+ dom.dispatch(
121
+ dom.window.document.body,
122
+ new dom.window.MouseEvent("mousedown", { bubbles: true }),
123
+ );
124
+ assert.equal(isOpen(), false);
125
+ });
126
+
127
+ it("refuses to open, and says why, when the selection spans accounts", () => {
128
+ const hint = "Select messages from one account to move them";
129
+ const dom = mount({ mailboxes: FOLDERS, disabledHint: hint });
130
+ const trigger = dom.byLabel("Move to folder");
131
+ assert.equal(trigger.getAttribute("title"), hint);
132
+
133
+ dom.click(trigger);
134
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
135
+ });
136
+
137
+ it("opens a dialog rather than a popover on a phone", () => {
138
+ const dom = mount({ mailboxes: FOLDERS, viewportWidth: 390 });
139
+ assert.equal(
140
+ dom.byLabel("Move to folder").getAttribute("aria-haspopup"),
141
+ "dialog",
142
+ );
143
+ });
144
+
145
+ it("asks for the folder list only once the picker is opened", () => {
146
+ const dom = mount();
147
+ assert.equal(dom.queryAll("[role=option]").length, 0);
148
+ dom.click(dom.byLabel("Move to folder"));
149
+ // No cached mailboxes and no network in the test harness: the picker
150
+ // shows its loading state rather than an empty list of destinations.
151
+ assert.match(dom.text(), /Loading folders/);
152
+ });
153
+ });
@@ -0,0 +1,145 @@
1
+ /**
2
+ * The bulk-action bar. Two rules govern it: actions are never disabled (they
3
+ * no-op and explain — `doc/rules/ux.md`), and Move/Organize only appear when
4
+ * the selection is inside a single account, because neither works across
5
+ * accounts.
6
+ */
7
+
8
+ import assert from "node:assert/strict";
9
+ import { afterEach, describe, it } from "node:test";
10
+ import { createElement } from "react";
11
+ import { createDomHarness, type DomHarness } from "../../test-support/dom";
12
+ import { SelectionToolbar } from "./SelectionToolbar";
13
+
14
+ let harness: DomHarness | undefined;
15
+
16
+ afterEach(() => {
17
+ harness?.close();
18
+ harness = undefined;
19
+ });
20
+
21
+ type ToolbarProps = Parameters<typeof SelectionToolbar>[0];
22
+
23
+ const mount = (props: Partial<ToolbarProps> = {}): DomHarness => {
24
+ harness = createDomHarness();
25
+ harness.renderApp(
26
+ createElement(SelectionToolbar, {
27
+ selectedCount: 2,
28
+ onDelete: () => undefined,
29
+ onClearSelection: () => undefined,
30
+ ...props,
31
+ }),
32
+ );
33
+ return harness;
34
+ };
35
+
36
+ describe("SelectionToolbar", () => {
37
+ it("renders nothing while nothing is selected", () => {
38
+ const dom = mount({ selectedCount: 0 });
39
+ assert.equal(dom.html(), "");
40
+ });
41
+
42
+ it("counts the selection in words the user reads", () => {
43
+ assert.match(mount({ selectedCount: 1 }).text(), /1 message selected/);
44
+ harness?.close();
45
+ harness = undefined;
46
+ assert.match(mount({ selectedCount: 4 }).text(), /4 messages selected/);
47
+ });
48
+
49
+ it("hides Move until both an account and a source mailbox are known", () => {
50
+ const dom = mount({ onMove: () => undefined, accountId: "acc-1" });
51
+ assert.equal(dom.query('[aria-label="Move selected messages"]'), null);
52
+ });
53
+
54
+ it("offers Move and Organize once the selection sits in one account", () => {
55
+ const dom = mount({
56
+ onMove: () => undefined,
57
+ onOrganize: () => undefined,
58
+ accountId: "acc-1",
59
+ currentMailboxId: "mbx-inbox",
60
+ });
61
+ assert.ok(dom.query('[aria-label="Move selected messages"]'));
62
+ assert.ok(dom.query('[aria-label="Organize similar messages"]'));
63
+ });
64
+
65
+ it("withdraws Organize and explains why when the selection spans accounts", () => {
66
+ const dom = mount({
67
+ onMove: () => undefined,
68
+ onOrganize: () => undefined,
69
+ accountId: "acc-1",
70
+ currentMailboxId: "mbx-inbox",
71
+ moveDisabledHint: "Select messages from one account to move them",
72
+ });
73
+ assert.equal(dom.query('[aria-label="Organize similar messages"]'), null);
74
+ assert.match(dom.text(), /Select messages from one account to move them/);
75
+ assert.match(
76
+ dom.query('[role="status"]')?.textContent ?? "",
77
+ /one account/,
78
+ );
79
+ });
80
+
81
+ it("keeps every action pressable while a mutation is in flight (ux.md)", () => {
82
+ const dom = mount({ isDeleting: true, onMarkAsRead: () => undefined });
83
+ for (const button of dom.queryAll<HTMLButtonElement>("button")) {
84
+ assert.equal(button.disabled, false);
85
+ }
86
+ assert.equal(
87
+ dom.byLabel("Delete selected messages").getAttribute("aria-busy"),
88
+ "true",
89
+ );
90
+ assert.match(dom.text(), /Deleting\.\.\./);
91
+ });
92
+
93
+ it("no-ops rather than firing a second time while busy", () => {
94
+ let deletes = 0;
95
+ let marks = 0;
96
+ const dom = mount({
97
+ isMoving: true,
98
+ onDelete: () => {
99
+ deletes += 1;
100
+ },
101
+ onMarkAsRead: () => {
102
+ marks += 1;
103
+ },
104
+ });
105
+ dom.click(dom.byLabel("Delete selected messages"));
106
+ dom.click(dom.byLabel("Mark as read"));
107
+ assert.equal(deletes, 0);
108
+ assert.equal(marks, 0);
109
+ });
110
+
111
+ it("fires delete, mark-read and clear when idle", () => {
112
+ let cleared = 0;
113
+ let deletes = 0;
114
+ let marks = 0;
115
+ const dom = mount({
116
+ onDelete: () => {
117
+ deletes += 1;
118
+ },
119
+ onClearSelection: () => {
120
+ cleared += 1;
121
+ },
122
+ onMarkAsRead: () => {
123
+ marks += 1;
124
+ },
125
+ });
126
+ dom.click(dom.byLabel("Delete selected messages"));
127
+ dom.click(dom.byLabel("Mark as read"));
128
+ dom.click(dom.byLabel("Clear selection"));
129
+ assert.deepEqual([deletes, marks, cleared], [1, 1, 1]);
130
+ });
131
+
132
+ it("opens the organize flow for the current selection", () => {
133
+ let organized = 0;
134
+ const dom = mount({
135
+ onMove: () => undefined,
136
+ accountId: "acc-1",
137
+ currentMailboxId: "mbx-inbox",
138
+ onOrganize: () => {
139
+ organized += 1;
140
+ },
141
+ });
142
+ dom.click(dom.byLabel("Organize similar messages"));
143
+ assert.equal(organized, 1);
144
+ });
145
+ });
@@ -13,7 +13,10 @@ import type { JSDOM } from "jsdom";
13
13
  import { act, createElement, createRef, useState } from "react";
14
14
  import { createRoot, type Root } from "react-dom/client";
15
15
  import type { MessageListCommands } from "./MessageList";
16
- import { ThreadListInteraction } from "./ThreadListInteraction";
16
+ import {
17
+ ThreadListInteraction,
18
+ useThreadListSelection,
19
+ } from "./ThreadListInteraction";
17
20
 
18
21
  let dom: JSDOM;
19
22
  let container: HTMLElement;
@@ -197,3 +200,80 @@ describe("ThreadListInteraction — delete confirms first", () => {
197
200
  });
198
201
  });
199
202
  });
203
+
204
+ /**
205
+ * A background refresh drops the ids that left and keeps every survivor — the
206
+ * same intersect-on-refresh guarantee `useSelection.test.ts` locks for the pure
207
+ * helper (#111), here through the live provider whose effect runs it whenever
208
+ * the rendered rows change (a chip filter, a collapsed section, mail deleted
209
+ * elsewhere).
210
+ */
211
+ function mountSelectableList(initialIds: string[]) {
212
+ const commandsRef = createRef<MessageListCommands | null>();
213
+ let setIds: ((ids: string[]) => void) | undefined;
214
+ let selected: string[] = [];
215
+ const Probe = () => {
216
+ const { selectedIds } = useThreadListSelection();
217
+ selected = Array.from(selectedIds).sort();
218
+ return null;
219
+ };
220
+ const Harness = () => {
221
+ const [ids, set] = useState(initialIds);
222
+ setIds = set;
223
+ return createElement(
224
+ ThreadListInteraction,
225
+ { selectedMessageId: undefined, onOpen: () => undefined, commandsRef },
226
+ ...rowElements(ids),
227
+ createElement(Probe, { key: "probe" }),
228
+ );
229
+ };
230
+ act(() => root.render(createElement(Harness)));
231
+ return {
232
+ commands: () => {
233
+ const commands = commandsRef.current;
234
+ if (!commands) throw new Error("commands not published");
235
+ return commands;
236
+ },
237
+ render: async (ids: string[]) => {
238
+ await act(async () => {
239
+ setIds?.(ids);
240
+ });
241
+ },
242
+ selected: () => selected,
243
+ };
244
+ }
245
+
246
+ describe("ThreadListInteraction — selection survives a background refresh (#111)", () => {
247
+ it("keeps every survivor when a refresh drops one selected row", async () => {
248
+ const list = mountSelectableList(["m1", "m2", "m3"]);
249
+
250
+ // jsdom has no matchMedia, so the provider runs in mobile multi-select
251
+ // mode: focusFirst seeds the cursor, x toggles it in, and focusLast then
252
+ // toggles the row it lands on straight into the selection.
253
+ act(() => list.commands().focusFirst());
254
+ act(() => list.commands().toggleSelect());
255
+ act(() => list.commands().focusLast());
256
+ assert.deepEqual(list.selected(), ["m1", "m3"]);
257
+
258
+ // m3 leaves the rendered set (deleted elsewhere / filtered out); m2 is
259
+ // present but was never selected.
260
+ await list.render(["m1", "m2"]);
261
+
262
+ assert.deepEqual(
263
+ list.selected(),
264
+ ["m1"],
265
+ "the survivor stays selected, the departed id is dropped, and nothing new is added",
266
+ );
267
+ });
268
+
269
+ it("empties the selection only when every selected row leaves", async () => {
270
+ const list = mountSelectableList(["m1", "m2"]);
271
+ act(() => list.commands().focusFirst());
272
+ act(() => list.commands().toggleSelect());
273
+ assert.deepEqual(list.selected(), ["m1"]);
274
+
275
+ await list.render(["m9"]);
276
+
277
+ assert.deepEqual(list.selected(), []);
278
+ });
279
+ });