@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remit/web-client",
3
- "version": "0.0.214",
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
- { key: "j", handler: focusNext, preventDefault: true },
281
- { key: "ArrowDown", handler: focusNext, preventDefault: true },
282
- { key: "k", handler: focusPrevious, preventDefault: true },
283
- { key: "ArrowUp", handler: focusPrevious, preventDefault: true },
284
- { key: "Enter", handler: toggleFocusedMessage, preventDefault: true },
285
- { key: "o", handler: toggleFocusedMessage, preventDefault: true },
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
- * D4a: an expunge inside a Trash reader only matched by name still goes
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.match(text, /They are in Deleted Messages/);
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
- * Three of the outcomes are not confirmations at all. When the account has no
9
- * Trash, or the folder it appointed is gone, the server refuses the delete
10
- * outright; when its folder settings could not be read reader cannot say
11
- * whether a delete moves the mail or erases it. All three refuse and the
12
- * caller's `onConfirm` is never reached directly. The first two are answered
13
- * in place — the affirmative control opens the appointment prompt, whose own
14
- * confirm appoints the folder and then runs this delete. None of them may
15
- * render as "this folder is not Trash".
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
- /** That folder is a name match nobody ever confirmed. */
38
- trashIsUnconfirmed?: boolean;
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
- trashIsUnconfirmed = false,
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 (outcome === "noTrash" || outcome === "staleTrash") {
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: outcome === "noTrash" ? "none" : "stale",
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
- it("names an expunge as an expunge, however the run ended", () => {
96
- assert.match(
97
- runEndingBanner("delete", 100, ended(), "permanent")?.title ?? "",
98
- /^100 permanently deleted\./,
99
- );
100
- assert.match(
101
- runEndingBanner("delete", 3000, ended({ cancelled: true }), "permanent")
102
- ?.detail ?? "",
103
- /100 of 3,000 permanently deleted\./,
104
- "the half that ran is erased whether or not the rest did",
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
- trashIsUnconfirmed={trashIsUnconfirmed}
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
- trashIsUnconfirmed={trashIsUnconfirmed}
485
+ guessedMailboxId={guessedMailboxId}
486
486
  isDeleting={isDeleting}
487
487
  onConfirm={confirmDelete}
488
488
  onCancel={cancelDelete}