@remit/web-client 0.0.77 → 0.0.79
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/MailboxPane.tsx +1 -34
- package/src/hooks/useToggleStar.test.ts +1 -0
- package/src/hooks/useToggleTrusted.test.ts +1 -0
- package/src/lib/brief.test.ts +1 -0
- package/src/lib/dedupe-thread-messages.test.ts +1 -0
- package/src/lib/display-category.test.ts +4 -1
- package/src/lib/display-category.ts +4 -2
- package/src/lib/inbox-filters.test.ts +106 -0
- package/src/lib/inbox-filters.ts +44 -0
- package/src/test-support/fixtures.ts +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.79",
|
|
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": {
|
|
@@ -100,6 +100,7 @@ import {
|
|
|
100
100
|
type ConversationTarget,
|
|
101
101
|
} from "@/lib/conversation-target";
|
|
102
102
|
import { dedupeThreadMessages } from "@/lib/dedupe-thread-messages";
|
|
103
|
+
import { applyInboxFilters } from "@/lib/inbox-filters";
|
|
103
104
|
import { readIntelligencePref } from "@/lib/intelligence-pref";
|
|
104
105
|
import { useMailContext } from "@/lib/mail-context";
|
|
105
106
|
import { isRescueCandidate } from "@/lib/rescue-candidates";
|
|
@@ -117,40 +118,6 @@ import { parseSearchTokens } from "@/lib/search-tokens";
|
|
|
117
118
|
import { useTelemetry } from "@/lib/telemetry-context";
|
|
118
119
|
import { MailViewChrome } from "./MailViewChrome";
|
|
119
120
|
|
|
120
|
-
/* ------------------------------------------------------------------ */
|
|
121
|
-
/* Inbox filter predicates — the inbox preset offers Unread / Starred /
|
|
122
|
-
Has attachment (never accounts; an inbox is one account already).
|
|
123
|
-
The `flagged` id is the wire name for IMAP \Flagged; the label is
|
|
124
|
-
"Starred". */
|
|
125
|
-
/* ------------------------------------------------------------------ */
|
|
126
|
-
|
|
127
|
-
const INBOX_FILTER_PREDICATES: Record<
|
|
128
|
-
string,
|
|
129
|
-
(t: RemitImapThreadMessageResponse) => boolean
|
|
130
|
-
> = {
|
|
131
|
-
unread: (t) => !t.isRead,
|
|
132
|
-
flagged: (t) => t.hasStars === true,
|
|
133
|
-
attachment: (t) => Boolean(t.hasAttachment),
|
|
134
|
-
};
|
|
135
|
-
|
|
136
|
-
function applyInboxFilters(
|
|
137
|
-
threads: RemitImapThreadMessageResponse[],
|
|
138
|
-
category: string,
|
|
139
|
-
attributes: ReadonlySet<string>,
|
|
140
|
-
): RemitImapThreadMessageResponse[] {
|
|
141
|
-
const predicates = Array.from(attributes)
|
|
142
|
-
.map((id) => INBOX_FILTER_PREDICATES[id])
|
|
143
|
-
.filter(
|
|
144
|
-
(p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
|
|
145
|
-
);
|
|
146
|
-
if (category === "all" && predicates.length === 0) return threads;
|
|
147
|
-
return threads.filter(
|
|
148
|
-
(t) =>
|
|
149
|
-
(category === "all" || t.category === category) &&
|
|
150
|
-
predicates.every((p) => p(t)),
|
|
151
|
-
);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
121
|
/* ------------------------------------------------------------------ */
|
|
155
122
|
/* Context */
|
|
156
123
|
/* ------------------------------------------------------------------ */
|
package/src/lib/brief.test.ts
CHANGED
|
@@ -10,7 +10,10 @@ describe("toDisplayCategory", () => {
|
|
|
10
10
|
assert.strictEqual(toDisplayCategory("uncategorized"), "uncategorized");
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
test("treats
|
|
13
|
+
test("treats an absent category as unclassified", () => {
|
|
14
|
+
// No caller can reach this today — both response fields are required —
|
|
15
|
+
// but an absent category must resolve to `uncategorized`, never to
|
|
16
|
+
// `personal`, wherever one arrives from.
|
|
14
17
|
assert.strictEqual(toDisplayCategory(undefined), "uncategorized");
|
|
15
18
|
});
|
|
16
19
|
|
|
@@ -11,8 +11,10 @@ import type { ThreadCategory } from "@remit/ui";
|
|
|
11
11
|
* decided was personal, so a classification gap read as a large personal inbox
|
|
12
12
|
* rather than as missing work (issue #45).
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Both response fields that feed this are required, so no caller reaches the
|
|
15
|
+
* `undefined` branch. The parameter stays tolerant anyway: this is the one place
|
|
16
|
+
* an absent category is turned into a value, and the answer has to be
|
|
17
|
+
* `uncategorized` rather than a crash or a `personal` default.
|
|
16
18
|
*/
|
|
17
19
|
export function toDisplayCategory(
|
|
18
20
|
category: RemitImapMessageCategory | undefined,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, test } from "node:test";
|
|
3
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
4
|
+
import { applyInboxFilters } from "./inbox-filters.js";
|
|
5
|
+
|
|
6
|
+
// Only the fields the filter reads, following the fixture idiom in
|
|
7
|
+
// starred-rows.test.ts.
|
|
8
|
+
const thread = (
|
|
9
|
+
fields: Partial<RemitImapThreadMessageResponse> & { messageId: string },
|
|
10
|
+
): RemitImapThreadMessageResponse =>
|
|
11
|
+
({ isRead: false, ...fields }) as unknown as RemitImapThreadMessageResponse;
|
|
12
|
+
|
|
13
|
+
const personal = thread({ messageId: "m1", category: "personal" });
|
|
14
|
+
const unclassified = thread({ messageId: "m2", category: "uncategorized" });
|
|
15
|
+
const preClassification = thread({ messageId: "m3" });
|
|
16
|
+
|
|
17
|
+
const ids = (threads: RemitImapThreadMessageResponse[]): string[] =>
|
|
18
|
+
threads.map((t) => t.messageId);
|
|
19
|
+
|
|
20
|
+
describe("applyInboxFilters", () => {
|
|
21
|
+
test("returns the loaded list when nothing narrows it", () => {
|
|
22
|
+
const threads = [personal, unclassified, preClassification];
|
|
23
|
+
assert.deepEqual(applyInboxFilters(threads, "all", new Set()), threads);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("matches a thread whose category is set", () => {
|
|
27
|
+
assert.deepEqual(
|
|
28
|
+
ids(applyInboxFilters([personal, unclassified], "personal", new Set())),
|
|
29
|
+
["m1"],
|
|
30
|
+
);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("counts a thread with no category as unclassified (#45)", () => {
|
|
34
|
+
// A pre-classification thread already renders an `uncategorized` badge,
|
|
35
|
+
// so the Unclassified chip has to find the row the user can see. Reading
|
|
36
|
+
// the response field raw made that row vanish under its own chip.
|
|
37
|
+
assert.deepEqual(
|
|
38
|
+
ids(
|
|
39
|
+
applyInboxFilters(
|
|
40
|
+
[personal, unclassified, preClassification],
|
|
41
|
+
"uncategorized",
|
|
42
|
+
new Set(),
|
|
43
|
+
),
|
|
44
|
+
),
|
|
45
|
+
["m2", "m3"],
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("never lets unclassified mail answer to personal (#45)", () => {
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
applyInboxFilters(
|
|
52
|
+
[unclassified, preClassification],
|
|
53
|
+
"personal",
|
|
54
|
+
new Set(),
|
|
55
|
+
),
|
|
56
|
+
[],
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("applies attribute predicates alongside a category", () => {
|
|
61
|
+
const read = thread({
|
|
62
|
+
messageId: "m4",
|
|
63
|
+
category: "personal",
|
|
64
|
+
isRead: true,
|
|
65
|
+
});
|
|
66
|
+
assert.deepEqual(
|
|
67
|
+
ids(applyInboxFilters([personal, read], "personal", new Set(["unread"]))),
|
|
68
|
+
["m1"],
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("applies attribute predicates without a category", () => {
|
|
73
|
+
const read = thread({
|
|
74
|
+
messageId: "m4",
|
|
75
|
+
category: "newsletter",
|
|
76
|
+
isRead: true,
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(
|
|
79
|
+
ids(applyInboxFilters([personal, read], "all", new Set(["unread"]))),
|
|
80
|
+
["m1"],
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("matches starred and attachment threads", () => {
|
|
85
|
+
const starred = thread({ messageId: "m5", hasStars: true });
|
|
86
|
+
const withAttachment = thread({ messageId: "m6", hasAttachment: true });
|
|
87
|
+
const plain = thread({ messageId: "m7" });
|
|
88
|
+
const threads = [starred, withAttachment, plain];
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
ids(applyInboxFilters(threads, "all", new Set(["flagged"]))),
|
|
91
|
+
["m5"],
|
|
92
|
+
);
|
|
93
|
+
assert.deepEqual(
|
|
94
|
+
ids(applyInboxFilters(threads, "all", new Set(["attachment"]))),
|
|
95
|
+
["m6"],
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("ignores an attribute id with no predicate behind it", () => {
|
|
100
|
+
const threads = [personal, unclassified];
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
applyInboxFilters(threads, "all", new Set(["nonsense"])),
|
|
103
|
+
threads,
|
|
104
|
+
);
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { toDisplayCategory } from "./display-category.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inbox filter predicates — the inbox preset offers Unread / Starred / Has
|
|
6
|
+
* attachment (never accounts; an inbox is one account already). The `flagged`
|
|
7
|
+
* id is the wire name for IMAP \Flagged; the label is "Starred".
|
|
8
|
+
*/
|
|
9
|
+
const INBOX_FILTER_PREDICATES: Record<
|
|
10
|
+
string,
|
|
11
|
+
(t: RemitImapThreadMessageResponse) => boolean
|
|
12
|
+
> = {
|
|
13
|
+
unread: (t) => !t.isRead,
|
|
14
|
+
flagged: (t) => t.hasStars === true,
|
|
15
|
+
attachment: (t) => Boolean(t.hasAttachment),
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Narrow the loaded threads to one category and a set of attributes. Applied
|
|
20
|
+
* over the loaded pages until #306 moves the predicate into the query.
|
|
21
|
+
*
|
|
22
|
+
* The category comparison goes through `toDisplayCategory` so the inbox agrees
|
|
23
|
+
* with Starred and the brief, which filter mapped rows. `category` is optional
|
|
24
|
+
* on the response and absent reads as `uncategorized` everywhere else — a
|
|
25
|
+
* pre-classification thread renders an `uncategorized` badge, so a raw
|
|
26
|
+
* comparison made the row you can see vanish under its own chip (#45).
|
|
27
|
+
*/
|
|
28
|
+
export function applyInboxFilters(
|
|
29
|
+
threads: RemitImapThreadMessageResponse[],
|
|
30
|
+
category: string,
|
|
31
|
+
attributes: ReadonlySet<string>,
|
|
32
|
+
): RemitImapThreadMessageResponse[] {
|
|
33
|
+
const predicates = Array.from(attributes)
|
|
34
|
+
.map((id) => INBOX_FILTER_PREDICATES[id])
|
|
35
|
+
.filter(
|
|
36
|
+
(p): p is (t: RemitImapThreadMessageResponse) => boolean => p != null,
|
|
37
|
+
);
|
|
38
|
+
if (category === "all" && predicates.length === 0) return threads;
|
|
39
|
+
return threads.filter(
|
|
40
|
+
(t) =>
|
|
41
|
+
(category === "all" || toDisplayCategory(t.category) === category) &&
|
|
42
|
+
predicates.every((p) => p(t)),
|
|
43
|
+
);
|
|
44
|
+
}
|