@remit/web-client 0.0.214 → 0.0.216
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/BriefPane.tsx +2 -0
- package/src/components/mail/ConversationView.tsx +26 -6
- package/src/components/mail/DeleteConfirmDialog.render.test.ts +37 -21
- package/src/components/mail/DeleteConfirmDialog.tsx +26 -14
- package/src/components/mail/FlaggedPane.tsx +2 -0
- package/src/components/mail/MailboxPane.tsx +2 -0
- package/src/components/mail/MessageList.run-reporting.test.ts +21 -12
- package/src/components/mail/MessageList.tsx +2 -2
- package/src/components/mail/RoleAppointmentPromptProvider.tsx +1 -0
- package/src/components/mail/ThreadListInteraction.tsx +2 -2
- package/src/components/mail/two-window-listeners-one-key.render.test.ts +591 -0
- package/src/hooks/escalated-run-refusal.render.test.ts +156 -0
- package/src/hooks/useDeleteOutcome.ts +18 -13
- package/src/hooks/useEscalatedActions.ts +42 -2
- package/src/hooks/useTriageLayer.ts +26 -1
- package/src/lib/bulk-action-copy.ts +2 -2
- package/src/lib/format.test.ts +50 -41
- package/src/lib/format.ts +43 -32
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.216",
|
|
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": {
|
|
@@ -234,6 +234,7 @@ function BriefPaneProvider({ thread, children }: BriefPaneProps) {
|
|
|
234
234
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
235
235
|
// answer a row the cursor moved to while a reply was open.
|
|
236
236
|
enabled: !isComposing && !isReplying,
|
|
237
|
+
hasOpenThread: threadId !== undefined,
|
|
237
238
|
onClose: closeThread,
|
|
238
239
|
handlers: {
|
|
239
240
|
reply: () => replyToFocusedThread?.("reply"),
|
|
@@ -359,6 +360,7 @@ function BriefReading() {
|
|
|
359
360
|
selectedMessageId={conversation.messageId}
|
|
360
361
|
authenticity={conversation.authenticity}
|
|
361
362
|
onOpenIntelligence={intelligence.open}
|
|
363
|
+
listOnScreen
|
|
362
364
|
/>
|
|
363
365
|
) : (
|
|
364
366
|
<ReadingPaneEmpty />
|
|
@@ -56,6 +56,14 @@ interface ConversationViewProps {
|
|
|
56
56
|
onSwipePrevious?: () => void;
|
|
57
57
|
/** Whether the intelligence drawer is currently open (drives the ⓘ button pressed state). */
|
|
58
58
|
mobileIntelligenceOpen?: boolean;
|
|
59
|
+
/**
|
|
60
|
+
* The message list is on screen beside this conversation, so it — not this
|
|
61
|
+
* pane — owns j/k, the arrows and Enter (`shortcut-tree`: those sit at
|
|
62
|
+
* `detail` only where no list is visible). Both used to act on the same
|
|
63
|
+
* press, moving two cursors at once (#723). Answering the conversation is
|
|
64
|
+
* unaffected: r, R and f are the open thread's at every width.
|
|
65
|
+
*/
|
|
66
|
+
listOnScreen?: boolean;
|
|
59
67
|
}
|
|
60
68
|
|
|
61
69
|
const LoadingSkeleton = () => (
|
|
@@ -87,6 +95,7 @@ export const ConversationView = ({
|
|
|
87
95
|
onSwipeNext,
|
|
88
96
|
onSwipePrevious,
|
|
89
97
|
mobileIntelligenceOpen,
|
|
98
|
+
listOnScreen = false,
|
|
90
99
|
}: ConversationViewProps) => {
|
|
91
100
|
const isDesktop = useIsDesktop();
|
|
92
101
|
const { handlers: swipeHandlers } = useSwipeNavigation({
|
|
@@ -277,12 +286,23 @@ export const ConversationView = ({
|
|
|
277
286
|
useKeyboardNavigation({
|
|
278
287
|
enabled: !isLoading && messages.length > 0 && reply === undefined,
|
|
279
288
|
bindings: [
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
289
|
+
// Left unbound rather than bound and guarded: an unbound key is never
|
|
290
|
+
// preventDefault-ed, so it reaches the list's own window listener
|
|
291
|
+
// instead of the two racing on one press.
|
|
292
|
+
...(listOnScreen
|
|
293
|
+
? []
|
|
294
|
+
: [
|
|
295
|
+
{ key: "j", handler: focusNext, preventDefault: true },
|
|
296
|
+
{ key: "ArrowDown", handler: focusNext, preventDefault: true },
|
|
297
|
+
{ key: "k", handler: focusPrevious, preventDefault: true },
|
|
298
|
+
{ key: "ArrowUp", handler: focusPrevious, preventDefault: true },
|
|
299
|
+
{
|
|
300
|
+
key: "Enter",
|
|
301
|
+
handler: toggleFocusedMessage,
|
|
302
|
+
preventDefault: true,
|
|
303
|
+
},
|
|
304
|
+
{ key: "o", handler: toggleFocusedMessage, preventDefault: true },
|
|
305
|
+
]),
|
|
286
306
|
{ key: "r", handler: handleReply, preventDefault: true },
|
|
287
307
|
{
|
|
288
308
|
key: "R",
|
|
@@ -144,7 +144,6 @@ const mount = (options: {
|
|
|
144
144
|
accountId?: string;
|
|
145
145
|
trashFolderLabel?: string;
|
|
146
146
|
staleFolderLabel?: string;
|
|
147
|
-
trashIsUnconfirmed?: boolean;
|
|
148
147
|
authProvider?: AuthProvider;
|
|
149
148
|
onConfirm?: (messageIds: string[]) => void;
|
|
150
149
|
}) => {
|
|
@@ -174,7 +173,6 @@ const mount = (options: {
|
|
|
174
173
|
accountId: options.accountId,
|
|
175
174
|
trashFolderLabel: options.trashFolderLabel,
|
|
176
175
|
staleFolderLabel: options.staleFolderLabel,
|
|
177
|
-
trashIsUnconfirmed: options.trashIsUnconfirmed,
|
|
178
176
|
isDeleting: options.isDeleting,
|
|
179
177
|
onConfirm,
|
|
180
178
|
onCancel: () => undefined,
|
|
@@ -325,6 +323,40 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
|
|
|
325
323
|
assert.match(view.text(), /The Trash folder you chose is gone/);
|
|
326
324
|
});
|
|
327
325
|
|
|
326
|
+
// #876: a row already inside the folder reader only matched by name refuses
|
|
327
|
+
// like Empty Trash's own "unconfirmed" — not a plain "Delete permanently"
|
|
328
|
+
// the server would 409 on.
|
|
329
|
+
it("asks to confirm the guessed folder before an expunge nobody confirmed", async () => {
|
|
330
|
+
const view = mount({
|
|
331
|
+
outcome: "unconfirmed",
|
|
332
|
+
count: 3,
|
|
333
|
+
accountId: ACCOUNT,
|
|
334
|
+
trashFolderLabel: "Deleted Messages",
|
|
335
|
+
});
|
|
336
|
+
const text = view.text();
|
|
337
|
+
assert.match(text, /Confirm this account's Trash folder/);
|
|
338
|
+
assert.match(text, /Deleted Messages/);
|
|
339
|
+
assert.doesNotMatch(text, /Permanently delete 3 messages\?/);
|
|
340
|
+
const confirm = view.button("Confirm the folder");
|
|
341
|
+
assert.ok(confirm, "the remedy the copy names is on screen");
|
|
342
|
+
act(() => confirm?.click());
|
|
343
|
+
// DeleteConfirmDialog's own "unconfirmed" copy shares its title with the
|
|
344
|
+
// appointment prompt's, so the title alone cannot tell the two dialogs
|
|
345
|
+
// apart — matching only that string would pass whether or not the
|
|
346
|
+
// prompt actually replaced the dialog. The picker prompt is the
|
|
347
|
+
// prompt's own wording and never appears in DeleteConfirmDialog's copy.
|
|
348
|
+
assert.match(
|
|
349
|
+
view.text(),
|
|
350
|
+
/Confirm Deleted Messages, or pick the folder this account really uses\./,
|
|
351
|
+
);
|
|
352
|
+
await settle();
|
|
353
|
+
act(() => view.byLabel("Set Prullenbak, 3 messages, as Trash")?.click());
|
|
354
|
+
assert.ok(
|
|
355
|
+
view.button("Set as Trash and delete 3 messages"),
|
|
356
|
+
"the folder picker and the delete-specific confirm label are the prompt's own, not the dialog's",
|
|
357
|
+
);
|
|
358
|
+
});
|
|
359
|
+
|
|
328
360
|
it("still acts when no single account owns the rows", () => {
|
|
329
361
|
const confirmed: string[][] = [];
|
|
330
362
|
const view = mount({
|
|
@@ -369,28 +401,12 @@ describe("DeleteConfirmDialog — a refusal answers itself", () => {
|
|
|
369
401
|
});
|
|
370
402
|
});
|
|
371
403
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
* through — the user asked for these specific rows — but they are told which
|
|
375
|
-
* folder that is, and that nobody chose it, before it happens.
|
|
376
|
-
*/
|
|
377
|
-
describe("DeleteConfirmDialog — an expunge inside a Trash nobody confirmed", () => {
|
|
378
|
-
it("keeps today's words for a confirmed Trash", () => {
|
|
404
|
+
describe("DeleteConfirmDialog — an expunge inside a confirmed Trash", () => {
|
|
405
|
+
it("asks for the expunge itself, with no folder to confirm first", () => {
|
|
379
406
|
const view = mount({ outcome: "permanent", count: 3 });
|
|
380
|
-
assert.doesNotMatch(view.text(), /nobody confirmed it/);
|
|
381
|
-
});
|
|
382
|
-
|
|
383
|
-
it("names the folder and says nobody chose it", () => {
|
|
384
|
-
const view = mount({
|
|
385
|
-
outcome: "permanent",
|
|
386
|
-
count: 3,
|
|
387
|
-
trashFolderLabel: "Deleted Messages",
|
|
388
|
-
trashIsUnconfirmed: true,
|
|
389
|
-
});
|
|
390
407
|
const text = view.text();
|
|
391
408
|
assert.match(text, /Permanently delete 3 messages\?/);
|
|
392
|
-
assert.
|
|
393
|
-
assert.match(text, /nobody confirmed it/);
|
|
409
|
+
assert.doesNotMatch(text, /nobody confirmed it/);
|
|
394
410
|
assert.equal(view.button("Delete permanently")?.disabled, false);
|
|
395
411
|
});
|
|
396
412
|
});
|
|
@@ -5,14 +5,15 @@
|
|
|
5
5
|
* and Flagged — so the wording and the refusal cannot drift apart again the way
|
|
6
6
|
* they did between #845 and #855.
|
|
7
7
|
*
|
|
8
|
-
*
|
|
9
|
-
* Trash,
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
8
|
+
* Four of the outcomes are not confirmations at all. When the account has no
|
|
9
|
+
* Trash, the folder it appointed is gone, or the row would expunge inside a
|
|
10
|
+
* Trash nobody ever confirmed, the server refuses the delete outright; when
|
|
11
|
+
* its folder settings could not be read reader cannot say whether a delete
|
|
12
|
+
* moves the mail or erases it. All four refuse and the caller's `onConfirm` is
|
|
13
|
+
* never reached directly. The first three are answered in place — the
|
|
14
|
+
* affirmative control opens the appointment prompt, whose own confirm appoints
|
|
15
|
+
* the folder and then runs this delete. None of them may render as "this
|
|
16
|
+
* folder is not Trash".
|
|
16
17
|
*/
|
|
17
18
|
import { ConfirmDialog } from "@remit/ui";
|
|
18
19
|
import { useAuthProvider } from "@/auth/provider";
|
|
@@ -34,8 +35,8 @@ interface DeleteConfirmDialogProps {
|
|
|
34
35
|
trashFolderLabel?: string;
|
|
35
36
|
/** The folder the user appointed, now gone from the mail server. */
|
|
36
37
|
staleFolderLabel?: string;
|
|
37
|
-
/**
|
|
38
|
-
|
|
38
|
+
/** `unconfirmed`: that guessed folder by id, so the prompt opens on it. */
|
|
39
|
+
guessedMailboxId?: string;
|
|
39
40
|
/** A delete is already in flight, so the confirm cannot be pressed again. */
|
|
40
41
|
isDeleting?: boolean;
|
|
41
42
|
onConfirm: (messageIds: string[]) => void;
|
|
@@ -49,7 +50,7 @@ export const DeleteConfirmDialog = ({
|
|
|
49
50
|
accountId,
|
|
50
51
|
trashFolderLabel,
|
|
51
52
|
staleFolderLabel,
|
|
52
|
-
|
|
53
|
+
guessedMailboxId,
|
|
53
54
|
isDeleting = false,
|
|
54
55
|
onConfirm,
|
|
55
56
|
onCancel,
|
|
@@ -61,10 +62,19 @@ export const DeleteConfirmDialog = ({
|
|
|
61
62
|
const copy = deleteConfirmationCopy(count, outcome, {
|
|
62
63
|
trashFolderLabel,
|
|
63
64
|
staleFolderLabel,
|
|
64
|
-
trashIsUnconfirmed,
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
if (
|
|
67
|
+
if (
|
|
68
|
+
outcome === "noTrash" ||
|
|
69
|
+
outcome === "staleTrash" ||
|
|
70
|
+
outcome === "unconfirmed"
|
|
71
|
+
) {
|
|
72
|
+
const reason =
|
|
73
|
+
outcome === "noTrash"
|
|
74
|
+
? "none"
|
|
75
|
+
: outcome === "staleTrash"
|
|
76
|
+
? "stale"
|
|
77
|
+
: "unconfirmed";
|
|
68
78
|
return (
|
|
69
79
|
<ConfirmDialog
|
|
70
80
|
isOpen={isOpen}
|
|
@@ -79,9 +89,11 @@ export const DeleteConfirmDialog = ({
|
|
|
79
89
|
requestAppointment({
|
|
80
90
|
accountId,
|
|
81
91
|
role: "Trash",
|
|
82
|
-
reason
|
|
92
|
+
reason,
|
|
83
93
|
action: { kind: "delete", count },
|
|
84
94
|
staleFolderLabel,
|
|
95
|
+
trashFolderLabel,
|
|
96
|
+
guessedMailboxId,
|
|
85
97
|
onAppointed: async () => onConfirm(replay),
|
|
86
98
|
});
|
|
87
99
|
}}
|
|
@@ -240,6 +240,7 @@ function FlaggedPaneProvider({ thread, children }: FlaggedPaneProps) {
|
|
|
240
240
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
241
241
|
// answer a row the cursor moved to while a reply was open.
|
|
242
242
|
enabled: !isComposing && !isReplying,
|
|
243
|
+
hasOpenThread: threadId !== undefined,
|
|
243
244
|
onClose: closeThread,
|
|
244
245
|
handlers: {
|
|
245
246
|
reply: () => replyToFocusedThread?.("reply"),
|
|
@@ -362,6 +363,7 @@ function FlaggedReading() {
|
|
|
362
363
|
selectedMessageId={conversation.messageId}
|
|
363
364
|
authenticity={conversation.authenticity}
|
|
364
365
|
onOpenIntelligence={intelligence.open}
|
|
366
|
+
listOnScreen
|
|
365
367
|
/>
|
|
366
368
|
) : (
|
|
367
369
|
<ReadingPaneEmpty />
|
|
@@ -726,6 +726,7 @@ function MailboxPaneProvider({
|
|
|
726
726
|
// would otherwise fire at the message behind whatever is being typed — or
|
|
727
727
|
// answer a row the cursor moved to while a reply was open.
|
|
728
728
|
enabled: !isComposing && !isReplying,
|
|
729
|
+
hasOpenThread: threadId !== undefined,
|
|
729
730
|
onClose: closeThread,
|
|
730
731
|
handlers: {
|
|
731
732
|
reply: triageReply,
|
|
@@ -1123,6 +1124,7 @@ function MailboxReading() {
|
|
|
1123
1124
|
selectedMessageId={conversation.messageId}
|
|
1124
1125
|
authenticity={conversation.authenticity}
|
|
1125
1126
|
onOpenIntelligence={intelligence.open}
|
|
1127
|
+
listOnScreen
|
|
1126
1128
|
/>
|
|
1127
1129
|
) : (
|
|
1128
1130
|
<ReadingPaneEmpty />
|
|
@@ -5,6 +5,7 @@ import { describe, it } from "node:test";
|
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { runEndingBanner } from "@/lib/bulk-action-copy";
|
|
7
7
|
import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
8
|
+
import { type DeleteOutcome, deleteExpunges } from "@/lib/format";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* A run states how it ended, to whoever is still there to read it (#521). The
|
|
@@ -28,6 +29,8 @@ import type { BulkRunOutcome } from "@/lib/bulk-actions";
|
|
|
28
29
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
29
30
|
const source = readFileSync(resolve(here, "MessageList.tsx"), "utf8");
|
|
30
31
|
|
|
32
|
+
const expunging: DeleteOutcome[] = ["permanent", "unconfirmed"];
|
|
33
|
+
|
|
31
34
|
const ended = (over: Partial<BulkRunOutcome> = {}): BulkRunOutcome => ({
|
|
32
35
|
done: 100,
|
|
33
36
|
failedIds: [],
|
|
@@ -92,18 +95,24 @@ describe("reporting how a run ended", () => {
|
|
|
92
95
|
);
|
|
93
96
|
});
|
|
94
97
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
?.
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
98
|
+
// Both outcomes a row already inside Trash produces, because both erase it.
|
|
99
|
+
// `unconfirmed` is only ever reached that way (#876), and a ternary that knew
|
|
100
|
+
// only `permanent` reported an expunge as "moved to Trash".
|
|
101
|
+
for (const outcome of expunging) {
|
|
102
|
+
it(`names a ${outcome} delete as an expunge, however the run ended`, () => {
|
|
103
|
+
assert.ok(deleteExpunges(outcome), "the copy reads this predicate");
|
|
104
|
+
assert.match(
|
|
105
|
+
runEndingBanner("delete", 100, ended(), outcome)?.title ?? "",
|
|
106
|
+
/^100 permanently deleted\./,
|
|
107
|
+
);
|
|
108
|
+
assert.match(
|
|
109
|
+
runEndingBanner("delete", 3000, ended({ cancelled: true }), outcome)
|
|
110
|
+
?.detail ?? "",
|
|
111
|
+
/100 of 3,000 permanently deleted\./,
|
|
112
|
+
"the half that ran is erased whether or not the rest did",
|
|
113
|
+
);
|
|
114
|
+
});
|
|
115
|
+
}
|
|
107
116
|
});
|
|
108
117
|
|
|
109
118
|
describe("the run the wizard leaves behind", () => {
|
|
@@ -326,8 +326,8 @@ export const MessageList = ({
|
|
|
326
326
|
);
|
|
327
327
|
const {
|
|
328
328
|
outcome: deleteOutcome,
|
|
329
|
-
trashIsUnconfirmed,
|
|
330
329
|
staleFolderLabel,
|
|
330
|
+
guessedMailboxId,
|
|
331
331
|
} = useDeleteOutcome(deleteScope);
|
|
332
332
|
|
|
333
333
|
// Selection state
|
|
@@ -1352,7 +1352,7 @@ export const MessageList = ({
|
|
|
1352
1352
|
// mailbox is the Trash the copy has to name.
|
|
1353
1353
|
trashFolderLabel={listTitle}
|
|
1354
1354
|
staleFolderLabel={staleFolderLabel}
|
|
1355
|
-
|
|
1355
|
+
guessedMailboxId={guessedMailboxId}
|
|
1356
1356
|
isDeleting={isDeleting}
|
|
1357
1357
|
onConfirm={handleConfirmDelete}
|
|
1358
1358
|
onCancel={handleCancelDelete}
|
|
@@ -212,6 +212,7 @@ export const RoleAppointmentPromptProvider = ({
|
|
|
212
212
|
folders={folders}
|
|
213
213
|
delimiter={folderDelimiter(mailboxes)}
|
|
214
214
|
trashFolderLabel={trashFolderLabel}
|
|
215
|
+
guessedMailboxId={guessedMailboxId}
|
|
215
216
|
staleFolderLabel={
|
|
216
217
|
request.staleFolderLabel ?? trash?.staleAppointmentPath
|
|
217
218
|
}
|
|
@@ -287,8 +287,8 @@ export function ThreadListInteraction({
|
|
|
287
287
|
);
|
|
288
288
|
const {
|
|
289
289
|
outcome: deleteOutcome,
|
|
290
|
-
trashIsUnconfirmed,
|
|
291
290
|
staleFolderLabel,
|
|
291
|
+
guessedMailboxId,
|
|
292
292
|
} = useDeleteOutcome(pendingDelete?.targets ?? NO_TARGETS);
|
|
293
293
|
|
|
294
294
|
// A verb, routed the same way the bar routes its own (#477 1.4, #508). Over a
|
|
@@ -482,7 +482,7 @@ export function ThreadListInteraction({
|
|
|
482
482
|
outcome={deleteOutcome}
|
|
483
483
|
accountId={pendingDelete?.targets[0]?.accountId}
|
|
484
484
|
staleFolderLabel={staleFolderLabel}
|
|
485
|
-
|
|
485
|
+
guessedMailboxId={guessedMailboxId}
|
|
486
486
|
isDeleting={isDeleting}
|
|
487
487
|
onConfirm={confirmDelete}
|
|
488
488
|
onCancel={cancelDelete}
|