@remit/ui 0.0.91 → 0.0.93

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/ui",
3
- "version": "0.0.91",
3
+ "version": "0.0.93",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
@@ -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
@@ -36,6 +36,19 @@ export const FilterMoveWithManageLink: Story = {
36
36
  },
37
37
  };
38
38
 
39
+ /**
40
+ * A user-reported spam message (issue #648). Independent of the classifier/
41
+ * filter-move shapes above: the badge follows the message wherever it now
42
+ * lives, including a report that never moved the message at all (it was
43
+ * already in Junk).
44
+ */
45
+ export const ReportedAsSpam: Story = {
46
+ args: {
47
+ label: "Reported as spam",
48
+ onUndo: () => alert("Undo"),
49
+ },
50
+ };
51
+
39
52
  export const SideBySide: Story = {
40
53
  render: () => (
41
54
  <div className="flex flex-col items-start gap-3">
@@ -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 { briefCategories, categoryTone } from "./app-shell-types.js";
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;
@@ -198,31 +198,70 @@ export const WithSimilarMessages: Story = {
198
198
  };
199
199
 
200
200
  /**
201
- * The spam quick actions are symmetric and mutually exclusive, decided by the
202
- * mailbox the message is in. A message sitting in Junk is offered the way out.
201
+ * The spam quick actions are a contextual pair, decided by whether the message
202
+ * carries a spam report never by the mailbox it happens to sit in, since a
203
+ * report on a message already in Junk (the provider's own filter put it there)
204
+ * is a real, no-op-move case (issue #648). A reportable message offers
205
+ * "Report spam".
203
206
  */
204
- export const InJunk: Story = {
207
+ export const Reportable: Story = {
205
208
  args: {
206
209
  data: base,
207
- actions: { onNotSpam: () => {} },
210
+ actions: { onReportSpam: () => {} },
208
211
  },
209
212
  };
210
213
 
211
- /** The inverse, for a message anywhere else. */
212
- export const OutsideJunk: Story = {
214
+ /**
215
+ * Already reported: "Not spam" (the undo) is offered instead of "Report
216
+ * spam", and the panel names the message as reported. Driven by
217
+ * `actions.onNotSpam` being present, not by `flags.blocked` — a sender can be
218
+ * blocked manually, with no report on this particular message, and that must
219
+ * not read as "you reported this" (issue #648 review).
220
+ */
221
+ export const Reported: Story = {
213
222
  args: {
214
223
  data: base,
215
- actions: { onMarkSpam: () => {} },
224
+ actions: { onNotSpam: () => {} },
216
225
  },
217
226
  };
218
227
 
219
228
  /**
220
- * The move has been made. Neither action is offered: pressing the one that just
221
- * ran would ask the mail server to move the message to where it already is.
229
+ * Neither action is offered. The panel hides the pair rather than disabling
230
+ * it unlike VIP/Mute/Unsubscribe, which always render and go visibly
231
+ * unavailable with no handler (issue #51). The host's own wiring never
232
+ * actually reaches this: `resolveSpamAction` always returns one of the two,
233
+ * since every message either carries a spam report or doesn't. Kept as a
234
+ * defensive state for a host that doesn't wire the pair at all.
222
235
  */
223
- export const SpamActionSpent: Story = {
236
+ export const SpamActionUnavailable: Story = {
224
237
  args: {
225
238
  data: base,
226
239
  actions: {},
227
240
  },
228
241
  };
242
+
243
+ /**
244
+ * A "Report spam" press in flight. There's no optimistic update for this
245
+ * action (a report against a message already in Junk is a real no-op-move,
246
+ * issue #648), so without a pending label the button gives no visible
247
+ * response at all until the request lands — the dead-button failure the
248
+ * coding standards call the worst outcome. The button stays clickable
249
+ * throughout, same as the undo direction's `isUndoing`: the operation is
250
+ * idempotent, so a second press is safe, never a queued duplicate.
251
+ */
252
+ export const ReportSpamPending: Story = {
253
+ args: {
254
+ data: base,
255
+ actions: { onReportSpam: () => {} },
256
+ reportSpamPending: true,
257
+ },
258
+ };
259
+
260
+ /** The undo direction's equivalent — "Undoing…" while `notSpam` is in flight. */
261
+ export const NotSpamPending: Story = {
262
+ args: {
263
+ data: base,
264
+ actions: { onNotSpam: () => {} },
265
+ notSpamPending: true,
266
+ },
267
+ };
@@ -1,5 +1,4 @@
1
1
  import {
2
- Ban,
3
2
  BellOff,
4
3
  MailCheck,
5
4
  MailX,
@@ -112,6 +111,16 @@ export type SimilarMessageLinkComponent = (
112
111
  export interface SenderFlagsIntel {
113
112
  vip?: boolean;
114
113
  muted?: boolean;
114
+ /**
115
+ * True once the sender is blocked — via a spam report on some message from
116
+ * them, or a manual block in Settings → Senders, which is the only durable
117
+ * manage-and-undo surface for it now that this panel has no `Block` action
118
+ * of its own. Not used to decide the quick-actions pair below: it's a
119
+ * sender-wide flag, and a manual block with no report on THIS message would
120
+ * make "You reported this sender" false (issue #648 review). The pair and
121
+ * that line key off `actions.onNotSpam`/`onReportSpam` instead, which the
122
+ * host derives from this specific message's `Message.spamReport`.
123
+ */
115
124
  blocked?: boolean;
116
125
  unsubscribed?: boolean;
117
126
  }
@@ -127,20 +136,21 @@ export interface IntelligenceData {
127
136
  export interface IntelligenceQuickActions {
128
137
  onToggleVip?: () => void;
129
138
  onToggleMute?: () => void;
130
- /** Block navigates through a confirm dialog — the callback fires post-confirm. */
131
- onToggleBlock?: () => void;
132
139
  onToggleUnsubscribe?: () => void;
133
140
  onReclassify?: () => void;
134
141
  /**
135
- * "Not spam": move the message out of Junk and promote the sender to
136
- * Wellknown (issue #594). Wired only when the message is currently in Junk.
142
+ * "Not spam": undo a spam report clears the sender block and moves the
143
+ * message back where it came from (issue #648). Wired only when the
144
+ * message currently carries a spam report.
137
145
  */
138
146
  onNotSpam?: () => void;
139
147
  /**
140
- * "Mark spam": move the message into Junk and strip the sender's trust
141
- * (the inverse of "Not spam"). Wired only when the message is not in Junk.
148
+ * "Report spam": move the message into Junk and block the sender (the
149
+ * inverse of "Not spam", issue #648). Wired only when the message has not
150
+ * been reported yet. Replaces the former separate `Block` and `Mark spam`
151
+ * actions with one contextual control.
142
152
  */
143
- onMarkSpam?: () => void;
153
+ onReportSpam?: () => void;
144
154
  }
145
155
 
146
156
  /**
@@ -173,6 +183,19 @@ export interface IntelligencePanelProps {
173
183
  * Drawer header), so there is exactly one way back (#874).
174
184
  */
175
185
  hideCloseButton?: boolean;
186
+ /**
187
+ * True while a "Report spam" press is in flight. There is no optimistic
188
+ * update for this action (issue #648: the client can't predict whether a
189
+ * report against a message already in Junk is a no-op), so without this
190
+ * the button gives no visible response at all until the request lands — a
191
+ * dead button is the worst outcome. Swaps the label to "Reporting…"; the
192
+ * handler stays wired throughout (same as `AutoMovedBadge`'s `isUndoing`)
193
+ * because the operation is idempotent, so a second press mid-flight is
194
+ * safe rather than a queued duplicate.
195
+ */
196
+ reportSpamPending?: boolean;
197
+ /** True while a "Not spam" (undo) press is in flight. Same treatment as `reportSpamPending`. */
198
+ notSpamPending?: boolean;
176
199
  }
177
200
 
178
201
  const trustLabel: Record<
@@ -362,6 +385,8 @@ export function IntelligencePanel({
362
385
  similarLinkComponent,
363
386
  className,
364
387
  hideCloseButton = false,
388
+ reportSpamPending = false,
389
+ notSpamPending = false,
365
390
  }: IntelligencePanelProps) {
366
391
  const { sender, authenticity, category, flags = {}, similar } = data;
367
392
  const suspicious = authenticity.verdict === "mismatch";
@@ -434,35 +459,34 @@ export function IntelligencePanel({
434
459
  active={flags.muted}
435
460
  onClick={actions?.onToggleMute}
436
461
  />
437
- <QuickAction
438
- icon={<Ban className="size-3.5" />}
439
- label="Block"
440
- active={flags.blocked}
441
- danger
442
- onClick={actions?.onToggleBlock}
443
- />
444
- <QuickAction
445
- icon={<MailX className="size-3.5" />}
446
- label="Unsubscribe"
447
- active={flags.unsubscribed}
448
- onClick={actions?.onToggleUnsubscribe}
449
- />
450
462
  {actions?.onNotSpam && (
451
463
  <QuickAction
452
464
  icon={<MailCheck className="size-3.5" />}
453
- label="Not spam"
465
+ label={notSpamPending ? "Undoing…" : "Not spam"}
466
+ active
454
467
  onClick={actions.onNotSpam}
455
468
  />
456
469
  )}
457
- {actions?.onMarkSpam && (
470
+ {actions?.onReportSpam && (
458
471
  <QuickAction
459
472
  icon={<ShieldX className="size-3.5" />}
460
- label="Mark spam"
473
+ label={reportSpamPending ? "Reporting…" : "Report spam"}
461
474
  danger
462
- onClick={actions.onMarkSpam}
475
+ onClick={actions.onReportSpam}
463
476
  />
464
477
  )}
478
+ <QuickAction
479
+ icon={<MailX className="size-3.5" />}
480
+ label="Unsubscribe"
481
+ active={flags.unsubscribed}
482
+ onClick={actions?.onToggleUnsubscribe}
483
+ />
465
484
  </div>
485
+ {actions?.onNotSpam && (
486
+ <p className="mt-1.5 text-2xs text-fg-subtle">
487
+ You reported this message as spam
488
+ </p>
489
+ )}
466
490
  </Section>
467
491
 
468
492
  {(similarState !== "ready" || similar.length > 0) && (
@@ -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 type {
8
- AppShellProps,
9
- MessageListKeyboard,
10
- MessageListSelection,
11
- TouchSeed,
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 ? (
@@ -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;
@@ -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(