@remit/ui 0.0.92 → 0.0.94
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/app-shell-types.ts +15 -0
- package/src/components/brief-sections.keyboard.test.ts +91 -3
- package/src/components/brief-sections.tsx +15 -1
- package/src/components/message-list-pane.tsx +8 -9
- package/src/components/selection-wizard.render.test.ts +34 -0
- package/src/components/selection-wizard.tsx +7 -0
- package/src/lib/roving-focus.test.ts +19 -2
- package/src/lib/roving-focus.ts +7 -1
- package/src/lib/wizard-steps.test.ts +25 -0
- package/src/lib/wizard-steps.ts +13 -3
package/package.json
CHANGED
|
@@ -104,6 +104,21 @@ export interface MessageListKeyboard {
|
|
|
104
104
|
ref: (element: HTMLElement | null) => void;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
+
/**
|
|
108
|
+
* Whether a layer answers the cursor keys, and so walks the rows itself. A list
|
|
109
|
+
* under one stands its own roving-focus group down: both own the arrows, and
|
|
110
|
+
* the group stops the press before the layer above hears it.
|
|
111
|
+
*/
|
|
112
|
+
export function keyboardWalksRows(
|
|
113
|
+
keyboard: MessageListKeyboard | undefined,
|
|
114
|
+
): keyboard is MessageListKeyboard {
|
|
115
|
+
if (keyboard === undefined) return false;
|
|
116
|
+
return (
|
|
117
|
+
keyboard.handlers.focusNext !== undefined &&
|
|
118
|
+
keyboard.handlers.focusPrevious !== undefined
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
107
122
|
/**
|
|
108
123
|
* Measures an element's OWN width via ResizeObserver — a container query, not a
|
|
109
124
|
* viewport one. The shell reflows by the space it actually occupies (so it works
|
|
@@ -5,9 +5,13 @@
|
|
|
5
5
|
import assert from "node:assert/strict";
|
|
6
6
|
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
7
7
|
import type { JSDOM } from "jsdom";
|
|
8
|
-
import { act, createElement } from "react";
|
|
8
|
+
import { act, createElement, useMemo } from "react";
|
|
9
9
|
import { createRoot, type Root } from "react-dom/client";
|
|
10
10
|
import { LIST_ROW_SELECTOR } from "../lib/roving-focus.js";
|
|
11
|
+
import {
|
|
12
|
+
type ListKeyboard,
|
|
13
|
+
useListKeyboard,
|
|
14
|
+
} from "../lib/use-list-keyboard.js";
|
|
11
15
|
import type { ThreadSection } from "./app-shell-types.js";
|
|
12
16
|
import { BriefSections } from "./brief-sections.js";
|
|
13
17
|
import { ComfortableRow } from "./message-row.js";
|
|
@@ -99,9 +103,9 @@ afterEach(() => {
|
|
|
99
103
|
});
|
|
100
104
|
});
|
|
101
105
|
|
|
102
|
-
function pressKey(target: Element, key: string) {
|
|
106
|
+
function pressKey(target: Element, key: string, shiftKey = false) {
|
|
103
107
|
target.dispatchEvent(
|
|
104
|
-
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
108
|
+
new dom.window.KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
|
|
105
109
|
);
|
|
106
110
|
}
|
|
107
111
|
|
|
@@ -173,3 +177,87 @@ describe("BriefSections arrow-key traversal", () => {
|
|
|
173
177
|
assert.equal(dom.window.document.activeElement, items[2]);
|
|
174
178
|
});
|
|
175
179
|
});
|
|
180
|
+
|
|
181
|
+
const orderedIds = sections.flatMap((section) =>
|
|
182
|
+
section.threads.map((thread) => thread.id),
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
let list: ListKeyboard | undefined;
|
|
186
|
+
|
|
187
|
+
function BriefUnderLayer() {
|
|
188
|
+
const ids = useMemo(() => orderedIds, []);
|
|
189
|
+
const keyboard = useListKeyboard({
|
|
190
|
+
orderedIds: ids,
|
|
191
|
+
isDesktop: true,
|
|
192
|
+
initialFocusedId: "t1",
|
|
193
|
+
});
|
|
194
|
+
list = keyboard;
|
|
195
|
+
return createElement(
|
|
196
|
+
"div",
|
|
197
|
+
{ ref: keyboard.keyboard.ref, tabIndex: -1 },
|
|
198
|
+
createElement(BriefSections, {
|
|
199
|
+
sections,
|
|
200
|
+
Row: ComfortableRow,
|
|
201
|
+
keyboard: keyboard.keyboard,
|
|
202
|
+
onSelectThread: () => undefined,
|
|
203
|
+
onSelectBriefCategory: () => undefined,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function mountUnderLayer() {
|
|
209
|
+
act(() => {
|
|
210
|
+
root.render(createElement(BriefUnderLayer));
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function selectedIds(): string[] {
|
|
215
|
+
return Array.from(list?.selection.selectedIds ?? []).sort();
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
describe("BriefSections under a keyboard layer", () => {
|
|
219
|
+
it("Shift+ArrowDown extends the selection down the rows", () => {
|
|
220
|
+
mountUnderLayer();
|
|
221
|
+
const items = rows();
|
|
222
|
+
|
|
223
|
+
act(() => items[0]?.focus());
|
|
224
|
+
act(() => pressKey(items[0] as Element, "ArrowDown", true));
|
|
225
|
+
act(() => pressKey(items[0] as Element, "ArrowDown", true));
|
|
226
|
+
|
|
227
|
+
assert.deepEqual(selectedIds(), ["t2", "t3"]);
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("Shift+ArrowUp extends the selection back up the rows", () => {
|
|
231
|
+
mountUnderLayer();
|
|
232
|
+
const items = rows();
|
|
233
|
+
|
|
234
|
+
act(() => items[0]?.focus());
|
|
235
|
+
act(() => pressKey(items[0] as Element, "ArrowDown"));
|
|
236
|
+
act(() => pressKey(items[0] as Element, "ArrowDown"));
|
|
237
|
+
act(() => pressKey(items[0] as Element, "ArrowUp", true));
|
|
238
|
+
act(() => pressKey(items[0] as Element, "ArrowUp", true));
|
|
239
|
+
|
|
240
|
+
assert.deepEqual(selectedIds(), ["t1", "t2"]);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
it("Shift+ArrowDown selects the rows Shift+J does", () => {
|
|
244
|
+
mountUnderLayer();
|
|
245
|
+
const items = rows();
|
|
246
|
+
|
|
247
|
+
act(() => items[0]?.focus());
|
|
248
|
+
act(() => pressKey(items[0] as Element, "j", true));
|
|
249
|
+
act(() => pressKey(items[0] as Element, "j", true));
|
|
250
|
+
|
|
251
|
+
assert.deepEqual(selectedIds(), ["t2", "t3"]);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it("hands the bare arrows to the layer instead of walking the rows itself", () => {
|
|
255
|
+
mountUnderLayer();
|
|
256
|
+
const items = rows();
|
|
257
|
+
|
|
258
|
+
act(() => items[0]?.focus());
|
|
259
|
+
act(() => pressKey(items[0] as Element, "ArrowDown"));
|
|
260
|
+
|
|
261
|
+
assert.equal(list?.cursor.focusedMessageId, "t2");
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -2,10 +2,15 @@ import { useRef, useState } from "react";
|
|
|
2
2
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
3
3
|
import type {
|
|
4
4
|
BriefCategoryFilter,
|
|
5
|
+
MessageListKeyboard,
|
|
5
6
|
ThreadRowData,
|
|
6
7
|
ThreadSection,
|
|
7
8
|
} from "./app-shell-types.js";
|
|
8
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
briefCategories,
|
|
11
|
+
categoryTone,
|
|
12
|
+
keyboardWalksRows,
|
|
13
|
+
} from "./app-shell-types.js";
|
|
9
14
|
import { BriefSection } from "./brief-section.js";
|
|
10
15
|
import {
|
|
11
16
|
FilterSheet,
|
|
@@ -135,6 +140,13 @@ interface BriefSectionsBaseProps
|
|
|
135
140
|
sections: ThreadSection[];
|
|
136
141
|
selectedThreadId?: string;
|
|
137
142
|
Row: BriefRowComponent;
|
|
143
|
+
/**
|
|
144
|
+
* The keyboard layer walking the rows, when the caller mounts one. The rows
|
|
145
|
+
* hand it the cursor keys rather than traversing with a roving group of their
|
|
146
|
+
* own, so the ring and the cursor name one row and a Shift+arrow range
|
|
147
|
+
* reaches the layer that extends it.
|
|
148
|
+
*/
|
|
149
|
+
keyboard?: MessageListKeyboard;
|
|
138
150
|
onSelectThread?: (id: string) => void;
|
|
139
151
|
/**
|
|
140
152
|
* Drop the filter row and its panel, keeping the rows where they are. See
|
|
@@ -160,6 +172,7 @@ export function BriefSections({
|
|
|
160
172
|
briefCategory = "all",
|
|
161
173
|
selectedThreadId,
|
|
162
174
|
Row,
|
|
175
|
+
keyboard,
|
|
163
176
|
onSelectThread,
|
|
164
177
|
onSelectBriefCategory,
|
|
165
178
|
sources,
|
|
@@ -179,6 +192,7 @@ export function BriefSections({
|
|
|
179
192
|
useRovingFocus({
|
|
180
193
|
containerRef: listRef,
|
|
181
194
|
itemSelector: LIST_ROW_SELECTOR,
|
|
195
|
+
enabled: !keyboardWalksRows(keyboard),
|
|
182
196
|
});
|
|
183
197
|
|
|
184
198
|
const active = activeFilters ?? ownFilters;
|
|
@@ -4,11 +4,12 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
4
4
|
import { defaultKeyboardHints, keyboardHintsFor } from "../lib/keymap.js";
|
|
5
5
|
import { LIST_ROW_SELECTOR, useRovingFocus } from "../lib/roving-focus.js";
|
|
6
6
|
import { deriveIsMultiSelectMode, modifiersOf } from "../lib/use-selection.js";
|
|
7
|
-
import
|
|
8
|
-
AppShellProps,
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
import {
|
|
8
|
+
type AppShellProps,
|
|
9
|
+
keyboardWalksRows,
|
|
10
|
+
type MessageListKeyboard,
|
|
11
|
+
type MessageListSelection,
|
|
12
|
+
type TouchSeed,
|
|
12
13
|
} from "./app-shell-types.js";
|
|
13
14
|
import { type BriefFilterSurface, BriefSections } from "./brief-sections.js";
|
|
14
15
|
import { Button } from "./button.js";
|
|
@@ -149,10 +150,7 @@ export function MessageListPane({
|
|
|
149
150
|
// The layer answers the arrows only if it registered them. Anything else it
|
|
150
151
|
// hands over — a layer with no cursor keys, or no layer at all — leaves the
|
|
151
152
|
// rows their own traversal and their own single tab stop.
|
|
152
|
-
const walksRows =
|
|
153
|
-
keyboard !== undefined &&
|
|
154
|
-
keyboard.handlers.focusNext !== undefined &&
|
|
155
|
-
keyboard.handlers.focusPrevious !== undefined;
|
|
153
|
+
const walksRows = keyboardWalksRows(keyboard);
|
|
156
154
|
useRovingFocus({
|
|
157
155
|
containerRef: flatListRef,
|
|
158
156
|
itemSelector: LIST_ROW_SELECTOR,
|
|
@@ -327,6 +325,7 @@ export function MessageListPane({
|
|
|
327
325
|
sections={sections}
|
|
328
326
|
selectedThreadId={selectedThreadId}
|
|
329
327
|
Row={BriefRow}
|
|
328
|
+
keyboard={keyboard}
|
|
330
329
|
onSelectThread={onSelectThread}
|
|
331
330
|
/>
|
|
332
331
|
) : listBody != null ? (
|
|
@@ -582,6 +582,24 @@ describe("RunStepBody", () => {
|
|
|
582
582
|
assert.match(html, /Nothing has changed\./);
|
|
583
583
|
});
|
|
584
584
|
|
|
585
|
+
// #522: the commit resolved no destination, which is a cause the screen knows
|
|
586
|
+
// and a setting the user can change.
|
|
587
|
+
it("names why a commit could not start, and where the fix is", () => {
|
|
588
|
+
const html = renderToString(
|
|
589
|
+
createElement(RunStepBody, {
|
|
590
|
+
...runProps,
|
|
591
|
+
state: "commitFailed",
|
|
592
|
+
verb: "junk",
|
|
593
|
+
scope: "once",
|
|
594
|
+
failureReason:
|
|
595
|
+
"This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.",
|
|
596
|
+
}),
|
|
597
|
+
);
|
|
598
|
+
assert.match(text(html), /no Junk folder appointed/);
|
|
599
|
+
assert.match(text(html), /Settings › Folders/);
|
|
600
|
+
assert.doesNotMatch(html, /Nothing has changed\./);
|
|
601
|
+
});
|
|
602
|
+
|
|
585
603
|
// A retry that could not be started is not a pass that never ran (#552): the
|
|
586
604
|
// pass that did run keeps its counts and its bar.
|
|
587
605
|
it("keeps a finished pass's counts when its retry could not be started", () => {
|
|
@@ -671,6 +689,22 @@ describe("RunFooter", () => {
|
|
|
671
689
|
assert.match(html, /Close/);
|
|
672
690
|
});
|
|
673
691
|
|
|
692
|
+
it("offers no retry for a commit the same press cannot get past", () => {
|
|
693
|
+
// A Try again here re-sends the identical commit to the same absent
|
|
694
|
+
// destination, forever (#522).
|
|
695
|
+
const html = renderToString(
|
|
696
|
+
createElement(RunFooter, {
|
|
697
|
+
...runProps,
|
|
698
|
+
state: "commitFailed",
|
|
699
|
+
verb: "junk",
|
|
700
|
+
scope: "once",
|
|
701
|
+
failureReason: "This account has no Junk folder appointed.",
|
|
702
|
+
}),
|
|
703
|
+
);
|
|
704
|
+
assert.doesNotMatch(html, /Try again/);
|
|
705
|
+
assert.match(html, /Close/);
|
|
706
|
+
});
|
|
707
|
+
|
|
674
708
|
it("offers only a way out once there is nothing outstanding", () => {
|
|
675
709
|
const html = renderToString(createElement(RunFooter, runProps));
|
|
676
710
|
assert.match(html, /Done/);
|
|
@@ -963,6 +963,12 @@ export interface RunStepProps {
|
|
|
963
963
|
* messages behind it; defaults to the ones named here.
|
|
964
964
|
*/
|
|
965
965
|
failedCount?: number;
|
|
966
|
+
/**
|
|
967
|
+
* Why the commit never started, when the run knows and sending the same one
|
|
968
|
+
* again cannot change it. Stated in place of the generic ending, and in place
|
|
969
|
+
* of the retry that would fail identically (#522).
|
|
970
|
+
*/
|
|
971
|
+
failureReason?: string;
|
|
966
972
|
onRetry: () => void;
|
|
967
973
|
onDismiss: () => void;
|
|
968
974
|
/**
|
|
@@ -981,6 +987,7 @@ const runOutcomeOf = (props: RunStepProps): RunOutcome => ({
|
|
|
981
987
|
matched: props.matched,
|
|
982
988
|
applied: props.applied,
|
|
983
989
|
failed: props.failedCount ?? props.failures.length,
|
|
990
|
+
failureReason: props.failureReason,
|
|
984
991
|
});
|
|
985
992
|
|
|
986
993
|
const runIcon = (tone: RunCopy["tone"]): ReactNode => {
|
|
@@ -93,9 +93,9 @@ afterEach(() => {
|
|
|
93
93
|
});
|
|
94
94
|
});
|
|
95
95
|
|
|
96
|
-
function pressKey(target: Element, key: string) {
|
|
96
|
+
function pressKey(target: Element, key: string, shiftKey = false) {
|
|
97
97
|
target.dispatchEvent(
|
|
98
|
-
new dom.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
98
|
+
new dom.window.KeyboardEvent("keydown", { key, bubbles: true, shiftKey }),
|
|
99
99
|
);
|
|
100
100
|
}
|
|
101
101
|
|
|
@@ -222,6 +222,23 @@ describe("useRovingFocus", () => {
|
|
|
222
222
|
assert.equal(dom.window.document.activeElement, rows()[0]);
|
|
223
223
|
});
|
|
224
224
|
|
|
225
|
+
it("leaves Shift+Arrow to the layer above instead of moving the cursor", () => {
|
|
226
|
+
mount({ count: 3 });
|
|
227
|
+
let seen = 0;
|
|
228
|
+
const spy = () => {
|
|
229
|
+
seen += 1;
|
|
230
|
+
};
|
|
231
|
+
dom.window.addEventListener("keydown", spy);
|
|
232
|
+
const items = rows();
|
|
233
|
+
act(() => items[0]?.focus());
|
|
234
|
+
act(() => pressKey(items[0] as Element, "ArrowDown", true));
|
|
235
|
+
act(() => pressKey(items[0] as Element, "ArrowUp", true));
|
|
236
|
+
dom.window.removeEventListener("keydown", spy);
|
|
237
|
+
|
|
238
|
+
assert.equal(dom.window.document.activeElement, items[0]);
|
|
239
|
+
assert.equal(seen, 2);
|
|
240
|
+
});
|
|
241
|
+
|
|
225
242
|
it("keeps a handled key from reaching a window-level listener", () => {
|
|
226
243
|
mount({ count: 3 });
|
|
227
244
|
let seen = 0;
|
package/src/lib/roving-focus.ts
CHANGED
|
@@ -69,7 +69,8 @@ function rovingItems(
|
|
|
69
69
|
* consumer-supplied row component, so neither has a flat array to index into.
|
|
70
70
|
*
|
|
71
71
|
* A handled key stops propagating, so a window-level keyboard layer above the
|
|
72
|
-
* group does not act on the same press.
|
|
72
|
+
* group does not act on the same press. Only the bare keys are handled — a
|
|
73
|
+
* modified arrow is a different binding, and it belongs to that layer.
|
|
73
74
|
*/
|
|
74
75
|
export function useRovingFocus({
|
|
75
76
|
containerRef,
|
|
@@ -95,6 +96,11 @@ export function useRovingFocus({
|
|
|
95
96
|
};
|
|
96
97
|
|
|
97
98
|
const onKeyDown = (event: KeyboardEvent) => {
|
|
99
|
+
// Shift+Arrow extends a selection; the group traverses on the bare key
|
|
100
|
+
// and leaves every modified stroke to the layer that binds it.
|
|
101
|
+
if (event.shiftKey || event.metaKey || event.ctrlKey || event.altKey) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
98
104
|
const items = rovingItems(container, itemSelector);
|
|
99
105
|
const currentIndex = items.indexOf(document.activeElement as HTMLElement);
|
|
100
106
|
const nextIndex = rovingNextIndex(
|
|
@@ -595,6 +595,31 @@ describe("runCopy", () => {
|
|
|
595
595
|
assert.equal(outcome("commitFailed", "once").title, "Couldn't start move");
|
|
596
596
|
});
|
|
597
597
|
|
|
598
|
+
// #522: a commit that resolved no destination fails the same way every time
|
|
599
|
+
// it is sent. Stating "Nothing has changed" over a Try again leaves the user
|
|
600
|
+
// pressing a control that can never work, with nothing naming the setting
|
|
601
|
+
// that would.
|
|
602
|
+
it("carries the reason a commit could not start, in place of a retry", () => {
|
|
603
|
+
const reason =
|
|
604
|
+
"This account has no Junk folder appointed, so there is nowhere to file these. Appoint one under Settings › Folders.";
|
|
605
|
+
const blocked = runCopy({
|
|
606
|
+
state: "commitFailed",
|
|
607
|
+
verb: "junk",
|
|
608
|
+
scope: "once",
|
|
609
|
+
matched: 0,
|
|
610
|
+
applied: 0,
|
|
611
|
+
failed: 0,
|
|
612
|
+
failureReason: reason,
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
assert.equal(blocked.detail, reason);
|
|
616
|
+
assert.doesNotMatch(blocked.detail, /Nothing has changed/);
|
|
617
|
+
assert.equal(blocked.retryLabel, undefined);
|
|
618
|
+
assert.equal(blocked.dismissLabel, "Close");
|
|
619
|
+
assert.equal(blocked.tone, "danger");
|
|
620
|
+
assert.match(blocked.title, /^Couldn't start/);
|
|
621
|
+
});
|
|
622
|
+
|
|
598
623
|
it("shows progress only while a pass over existing mail is under way or finished", () => {
|
|
599
624
|
assert.equal(outcome("saving", "standing").showProgress, false);
|
|
600
625
|
assert.equal(outcome("backApplyRunning", "standing").showProgress, true);
|
package/src/lib/wizard-steps.ts
CHANGED
|
@@ -334,6 +334,13 @@ export interface RunOutcome {
|
|
|
334
334
|
applied: number;
|
|
335
335
|
/** How many the mail server rejected. */
|
|
336
336
|
failed: number;
|
|
337
|
+
/**
|
|
338
|
+
* Why the commit never started, when sending the same one again cannot get
|
|
339
|
+
* past it — no Junk folder appointed, no destination chosen. The ending states
|
|
340
|
+
* this in place of the generic one and offers no retry, because the identical
|
|
341
|
+
* commit fails identically (#522).
|
|
342
|
+
*/
|
|
343
|
+
failureReason?: string;
|
|
337
344
|
}
|
|
338
345
|
|
|
339
346
|
export interface RunCopy {
|
|
@@ -372,6 +379,7 @@ export const runCopy = ({
|
|
|
372
379
|
matched,
|
|
373
380
|
applied,
|
|
374
381
|
failed,
|
|
382
|
+
failureReason,
|
|
375
383
|
}: RunOutcome): RunCopy => {
|
|
376
384
|
const { label, present, past } = verbCopy(verb);
|
|
377
385
|
const done = past.toLowerCase();
|
|
@@ -502,15 +510,17 @@ export const runCopy = ({
|
|
|
502
510
|
dismissLabel: "Done",
|
|
503
511
|
};
|
|
504
512
|
}
|
|
513
|
+
// A stated reason is a failure the same commit cannot get past, so the way
|
|
514
|
+
// out of it is the sentence rather than a retry that fails identically.
|
|
505
515
|
return {
|
|
506
516
|
...shared,
|
|
507
517
|
title: standing
|
|
508
518
|
? "Couldn't save the rule"
|
|
509
519
|
: `Couldn't start ${label.toLowerCase()}`,
|
|
510
|
-
detail: "Nothing has changed.",
|
|
520
|
+
detail: failureReason ?? "Nothing has changed.",
|
|
511
521
|
tone: "danger",
|
|
512
|
-
dismissLabel: "Not now",
|
|
513
|
-
retryLabel: "Try again",
|
|
522
|
+
dismissLabel: failureReason === undefined ? "Not now" : "Close",
|
|
523
|
+
retryLabel: failureReason === undefined ? "Try again" : undefined,
|
|
514
524
|
};
|
|
515
525
|
};
|
|
516
526
|
|