@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.
|
|
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
|
|
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
|
|
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
|
-
//
|
|
780
|
-
//
|
|
781
|
-
//
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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
|
+
};
|