@remit/web-client 0.0.192 → 0.0.193

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.192",
3
+ "version": "0.0.193",
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": {
@@ -101,6 +101,7 @@ import { useMailFreshness } from "@/lib/mail-freshness";
101
101
  import { relatedSearchResults, rowToSearchResult } from "@/lib/search-result";
102
102
  import { showInlineSearchResults } from "@/lib/search-surface";
103
103
  import { parseSearchTokens } from "@/lib/search-tokens";
104
+ import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
104
105
  import { spamOfferForResults } from "@/lib/spam-offer";
105
106
  import {
106
107
  type SelectionWizardControl,
@@ -206,30 +207,24 @@ export const resolveBriefSelectionScope = (
206
207
  selectedIds: ReadonlySet<string>,
207
208
  ): BriefSelectionScope => {
208
209
  if (selectedIds.size === 0) return {};
209
- const accountIds = new Set<string>();
210
+ const selected = rows.filter((row) => selectedIds.has(row.id));
211
+ const account = resolveSelectionAccountScope(
212
+ selected.map((row) => row.accountId),
213
+ );
214
+ if (account.restriction) return account;
210
215
  const mailboxIds = new Set<string>();
211
- for (const row of rows) {
212
- if (!selectedIds.has(row.id)) continue;
213
- if (row.accountId) accountIds.add(row.accountId);
216
+ for (const row of selected) {
214
217
  if (row.mailboxId) mailboxIds.add(row.mailboxId);
215
218
  }
216
- if (accountIds.size > 1) {
217
- return {
218
- restriction: "spansAccounts",
219
- moveDisabledHint:
220
- "Move only works within one account — clear selection or pick messages from a single account",
221
- };
222
- }
223
- const accountId = accountIds.size === 1 ? [...accountIds][0] : undefined;
224
219
  if (mailboxIds.size > 1) {
225
220
  return {
226
- accountId,
221
+ accountId: account.accountId,
227
222
  restriction: "spansFolders",
228
223
  moveDisabledHint: `Move only works within one folder — this selection spans ${mailboxIds.size} folders`,
229
224
  };
230
225
  }
231
226
  return {
232
- accountId,
227
+ accountId: account.accountId,
233
228
  mailboxId: mailboxIds.size === 1 ? [...mailboxIds][0] : undefined,
234
229
  };
235
230
  };
@@ -0,0 +1,64 @@
1
+ import assert from "node:assert/strict";
2
+ import { describe, it } from "node:test";
3
+ import { makeThreadMessage } from "@/test-support/fixtures";
4
+ import { resolveMoveDisabledHint } from "./MessageList";
5
+
6
+ /**
7
+ * Regression for #456: every account of one user shares an `accountConfigId`,
8
+ * so a guard built from it can never trip. These rows share one and differ only
9
+ * in `accountId`, which is the fact Move is scoped by.
10
+ */
11
+ const work = makeThreadMessage({ messageId: "m1", accountId: "acc-work" });
12
+ const personal = makeThreadMessage({
13
+ messageId: "m2",
14
+ accountId: "acc-personal",
15
+ });
16
+ const alsoWork = makeThreadMessage({ messageId: "m3", accountId: "acc-work" });
17
+ const unattributed = makeThreadMessage({
18
+ messageId: "m4",
19
+ accountId: undefined,
20
+ });
21
+
22
+ const hintFor = (
23
+ threads: ReturnType<typeof makeThreadMessage>[],
24
+ ids: string[],
25
+ listAccountId?: string,
26
+ ) => resolveMoveDisabledHint(threads, new Set(ids), listAccountId);
27
+
28
+ describe("the move guard over a thread-list selection", () => {
29
+ it("withholds Move from rows owned by different accounts", () => {
30
+ assert.match(
31
+ hintFor([work, personal], ["m1", "m2"]) ?? "",
32
+ /only works within one account/,
33
+ );
34
+ });
35
+
36
+ it("offers Move to rows owned by the same account", () => {
37
+ assert.equal(hintFor([work, alsoWork], ["m1", "m3"]), undefined);
38
+ });
39
+
40
+ it("ignores rows the selection does not hold", () => {
41
+ assert.equal(hintFor([work, personal], ["m1"]), undefined);
42
+ });
43
+
44
+ // A per-mailbox list attaches no account to its rows, so the list's own
45
+ // account stands in for them — otherwise the guard sees an empty set and
46
+ // under-fires on a selection that really does span two accounts.
47
+ it("withholds Move when a row without an account sits under another one", () => {
48
+ assert.match(
49
+ hintFor([unattributed, work], ["m4", "m1"], "acc-personal") ?? "",
50
+ /only works within one account/,
51
+ );
52
+ });
53
+
54
+ it("offers Move when the list's account is the one every row falls back to", () => {
55
+ assert.equal(
56
+ hintFor([unattributed, work], ["m4", "m1"], "acc-work"),
57
+ undefined,
58
+ );
59
+ });
60
+
61
+ it("offers Move when neither the rows nor the list name an account", () => {
62
+ assert.equal(hintFor([unattributed], ["m4"], undefined), undefined);
63
+ });
64
+ });
@@ -50,6 +50,7 @@ import { junkDestination } from "@/lib/junk-destination";
50
50
  import { tabStopId } from "@/lib/list-focus";
51
51
  import { useListHeaderChrome } from "@/lib/list-header-chrome";
52
52
  import { listVerbRequest } from "@/lib/list-verb-request";
53
+ import { resolveSelectionAccountScope } from "@/lib/selection-account-scope";
53
54
  import { shouldExitSelectionOnNavigate } from "@/lib/selection-mode";
54
55
  import { useSelectionWizard, useWizardStepValue } from "@/lib/wizard-history";
55
56
  import type { WizardSelectionMessage } from "@/lib/wizard-selection";
@@ -207,6 +208,23 @@ const SearchResultsHeader = ({ query }: { query: string }) => (
207
208
  </div>
208
209
  );
209
210
 
211
+ /**
212
+ * Why Move is withheld from a selection, in the toolbar's own words. Rows from
213
+ * a per-mailbox endpoint carry no account of their own, so the list's own
214
+ * account stands in for them and a row that does carry one is still compared
215
+ * against it. Pure, so it tests without a DOM.
216
+ */
217
+ export const resolveMoveDisabledHint = (
218
+ threads: readonly RemitImapThreadMessageResponse[],
219
+ selectedIds: ReadonlySet<string>,
220
+ listAccountId: string | undefined,
221
+ ): string | undefined =>
222
+ resolveSelectionAccountScope(
223
+ threads
224
+ .filter((thread) => selectedIds.has(thread.messageId))
225
+ .map((thread) => thread.accountId ?? listAccountId),
226
+ ).moveDisabledHint;
227
+
210
228
  export const MessageList = ({
211
229
  mailboxId,
212
230
  threads,
@@ -776,28 +794,13 @@ export const MessageList = ({
776
794
  setFocusedMessageId(restoreTo);
777
795
  }, [setFocusedMessageId]);
778
796
 
779
- // Cross-account guard: every selected thread row must belong to the
780
- // same account as the current mailbox. The list is already scoped to
781
- // one mailbox so in practice this is always single-account, but we
782
- // detect drift defensively (e.g. a future global selection mode) and
783
- // disable Move with an inline hint rather than silently aggregating.
784
- //
785
- // `MessageList` re-renders on every virtualizer scroll tick. Memoize
786
- // the guard so we only walk the selected slice when selection or
787
- // thread identity actually changes.
788
- const moveDisabledHint = useMemo(() => {
789
- if (selectedCount === 0) return undefined;
790
- const selectedAccountConfigIds = new Set<string>();
791
- for (const thread of threads) {
792
- if (selectedIds.has(thread.messageId)) {
793
- selectedAccountConfigIds.add(thread.accountConfigId);
794
- }
795
- }
796
- if (selectedAccountConfigIds.size > 1) {
797
- return "Move only works within one account — clear selection or pick messages from a single account";
798
- }
799
- return undefined;
800
- }, [selectedCount, selectedIds, threads]);
797
+ // `MessageList` re-renders on every virtualizer scroll tick. Memoize the
798
+ // guard so we only walk the selected slice when selection or thread
799
+ // identity actually changes.
800
+ const moveDisabledHint = useMemo(
801
+ () => resolveMoveDisabledHint(threads, selectedIds, accountId),
802
+ [accountId, selectedIds, threads],
803
+ );
801
804
 
802
805
  // Organize builds a rule out of clauses, and a search predicate is not a set
803
806
  // of clauses — its facets have no `ClauseField`. Over an escalated selection
@@ -0,0 +1,40 @@
1
+ import type { SelectionRestriction } from "@remit/ui";
2
+
3
+ /** The account a selection resolves to, and what that costs it. */
4
+ export interface SelectionAccountScope {
5
+ /** The one account holding every selected row, when there is one. */
6
+ accountId: string | undefined;
7
+ /** Which scope the selection spans more of than Move can take. */
8
+ restriction: SelectionRestriction | undefined;
9
+ /** Why Move is withheld, in the toolbar's own words. */
10
+ moveDisabledHint: string | undefined;
11
+ }
12
+
13
+ /**
14
+ * Move applies within one account, so every surface offering it over a
15
+ * selection asks the same question of the same fact — each row's own
16
+ * `accountId`, never `accountConfigId`, which every account of one user shares
17
+ * and so can never differ (#456). A row that carries no account of its own is
18
+ * the caller's to fill in from its list scope before it gets here.
19
+ */
20
+ export const resolveSelectionAccountScope = (
21
+ accountIds: Iterable<string | undefined>,
22
+ ): SelectionAccountScope => {
23
+ const distinct = new Set<string>();
24
+ for (const accountId of accountIds) {
25
+ if (accountId) distinct.add(accountId);
26
+ }
27
+ if (distinct.size > 1) {
28
+ return {
29
+ accountId: undefined,
30
+ restriction: "spansAccounts",
31
+ moveDisabledHint:
32
+ "Move only works within one account — clear selection or pick messages from a single account",
33
+ };
34
+ }
35
+ return {
36
+ accountId: distinct.size === 1 ? [...distinct][0] : undefined,
37
+ restriction: undefined,
38
+ moveDisabledHint: undefined,
39
+ };
40
+ };