@remit/web-client 0.0.7 → 0.0.9
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/FlaggedList.tsx +46 -12
- package/src/components/mail/SwipeableMessageRow.tsx +1 -2
- package/src/hooks/useStaleAccountSync.test.ts +31 -0
- package/src/hooks/useStaleAccountSync.ts +31 -5
- package/src/lib/drafts.ts +1 -2
- package/src/lib/starred-rows.test.ts +71 -0
- package/src/lib/starred-rows.ts +25 -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.9",
|
|
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": {
|
|
@@ -1,21 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* FlaggedList — a FLAT, cross-account inbox of starred mail.
|
|
3
3
|
*
|
|
4
|
-
* Reads
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
4
|
+
* Reads GET /threads with `starred=true`, which is served by the `byStarred`
|
|
5
|
+
* index and returns every starred thread in the config across all non-muted
|
|
6
|
+
* mailboxes, paged. Starredness is decided server-side from `hasStars`; the
|
|
7
|
+
* client neither re-filters nor caps the set, so a starred thread outside the
|
|
8
|
+
* newest inbox page still appears. Rendered as one continuous list (no category
|
|
9
|
+
* sections). The shared `MailViewChrome` owns the `MailHeader` + filter
|
|
10
|
+
* expando; the kit `MessageListPane` (flat, no `briefFilters`) owns the loading
|
|
11
|
+
* / empty / error chrome and keyboard hints, with a consumer-supplied
|
|
12
|
+
* `listBody` so the real rows render at every width.
|
|
10
13
|
*/
|
|
11
|
-
import {
|
|
14
|
+
import { unifiedThreadOperationsListAllThreadsQueryKey } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
15
|
+
import { unifiedThreadOperationsListAllThreads } from "@remit/api-http-client/sdk.gen.ts";
|
|
12
16
|
import {
|
|
13
17
|
ComfortableRow,
|
|
14
18
|
flaggedFilterConfig,
|
|
15
19
|
MessageListPane,
|
|
16
20
|
type ThreadRowData,
|
|
17
21
|
} from "@remit/ui";
|
|
18
|
-
import {
|
|
22
|
+
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
19
23
|
import { useCallback, useMemo, useState } from "react";
|
|
20
24
|
import { formatErrorMessage } from "@/components/ui/ErrorState";
|
|
21
25
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
@@ -28,6 +32,7 @@ import { buildBugReportContext, buildGitHubIssueUrl } from "@/lib/bug-report";
|
|
|
28
32
|
import { useMailContext } from "@/lib/mail-context";
|
|
29
33
|
import { rowToSearchResult } from "@/lib/search-result";
|
|
30
34
|
import { parseSearchTokens } from "@/lib/search-tokens";
|
|
35
|
+
import { dedupeByThread } from "@/lib/starred-rows";
|
|
31
36
|
import { MailViewChrome } from "./MailViewChrome";
|
|
32
37
|
|
|
33
38
|
const FILTER_PREDICATES: Record<string, (t: ThreadRowData) => boolean> = {
|
|
@@ -72,8 +77,26 @@ export function FlaggedList({
|
|
|
72
77
|
isError,
|
|
73
78
|
error,
|
|
74
79
|
refetch,
|
|
75
|
-
|
|
76
|
-
|
|
80
|
+
fetchNextPage,
|
|
81
|
+
hasNextPage,
|
|
82
|
+
isFetchingNextPage,
|
|
83
|
+
} = useInfiniteQuery({
|
|
84
|
+
queryKey: unifiedThreadOperationsListAllThreadsQueryKey({
|
|
85
|
+
query: { starred: true, order: "desc" },
|
|
86
|
+
}),
|
|
87
|
+
queryFn: async ({ pageParam }) => {
|
|
88
|
+
const { data } = await unifiedThreadOperationsListAllThreads({
|
|
89
|
+
query: {
|
|
90
|
+
starred: true,
|
|
91
|
+
order: "desc",
|
|
92
|
+
continuationToken: pageParam,
|
|
93
|
+
},
|
|
94
|
+
throwOnError: true,
|
|
95
|
+
});
|
|
96
|
+
return data;
|
|
97
|
+
},
|
|
98
|
+
initialPageParam: undefined as string | undefined,
|
|
99
|
+
getNextPageParam: (lastPage) => lastPage.continuationToken,
|
|
77
100
|
staleTime: 60_000,
|
|
78
101
|
});
|
|
79
102
|
|
|
@@ -86,9 +109,10 @@ export function FlaggedList({
|
|
|
86
109
|
const predicates = Array.from(activeFilters)
|
|
87
110
|
.map((id) => FILTER_PREDICATES[id])
|
|
88
111
|
.filter((p): p is (t: ThreadRowData) => boolean => p != null);
|
|
89
|
-
return (
|
|
112
|
+
return dedupeByThread(
|
|
113
|
+
(threadsData?.pages ?? []).flatMap((page) => page.items ?? []),
|
|
114
|
+
)
|
|
90
115
|
.map(toThreadRowData)
|
|
91
|
-
.filter((t) => t.starred === true)
|
|
92
116
|
.filter(
|
|
93
117
|
(t) =>
|
|
94
118
|
(selectedCategory === "all" || t.category === selectedCategory) &&
|
|
@@ -132,6 +156,16 @@ export function FlaggedList({
|
|
|
132
156
|
/>
|
|
133
157
|
))}
|
|
134
158
|
</div>
|
|
159
|
+
{hasNextPage ? (
|
|
160
|
+
<button
|
|
161
|
+
type="button"
|
|
162
|
+
className="w-full py-3 text-sm text-muted hover:text-fg disabled:opacity-50"
|
|
163
|
+
onClick={() => fetchNextPage()}
|
|
164
|
+
disabled={isFetchingNextPage}
|
|
165
|
+
>
|
|
166
|
+
{isFetchingNextPage ? "Loading…" : "Load more"}
|
|
167
|
+
</button>
|
|
168
|
+
) : null}
|
|
135
169
|
</div>
|
|
136
170
|
);
|
|
137
171
|
|
|
@@ -50,8 +50,7 @@ const toThreadRowData = (
|
|
|
50
50
|
timeLabel: formatEmailDate(thread.sentDate),
|
|
51
51
|
isRead: thread.isRead,
|
|
52
52
|
hasAttachment: thread.hasAttachment,
|
|
53
|
-
starred:
|
|
54
|
-
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
53
|
+
starred: thread.hasStars === true,
|
|
55
54
|
trust: thread.senderTrust,
|
|
56
55
|
category: toDisplayCategory(thread.category),
|
|
57
56
|
suspicious,
|
|
@@ -7,7 +7,9 @@ import {
|
|
|
7
7
|
__resetStaleAccountSyncGuard,
|
|
8
8
|
handleBackgroundSyncFailure,
|
|
9
9
|
hasPollIntervalElapsed,
|
|
10
|
+
MIN_POLL_INTERVAL_MS,
|
|
10
11
|
POLL_INTERVAL_MS,
|
|
12
|
+
resolvePollIntervalMs,
|
|
11
13
|
STALENESS_THRESHOLD_MS,
|
|
12
14
|
selectPollableAccountIds,
|
|
13
15
|
selectStaleAccountIds,
|
|
@@ -104,6 +106,35 @@ describe("POLL_INTERVAL_MS", () => {
|
|
|
104
106
|
});
|
|
105
107
|
});
|
|
106
108
|
|
|
109
|
+
describe("resolvePollIntervalMs", () => {
|
|
110
|
+
// This poll shares POST /sync with the refresh control, and that endpoint's
|
|
111
|
+
// triggers skip the server's per-mailbox freshness gate
|
|
112
|
+
// (MAILBOX_FRESHNESS_MS in imap-worker's sync-mailboxes fan-out). A timer is
|
|
113
|
+
// not a person: without this floor, configuring a sub-window interval turns
|
|
114
|
+
// every tick into a full folder-by-folder re-enumeration for every open
|
|
115
|
+
// account, which is exactly the fan-out storm the gate prevents. The floor
|
|
116
|
+
// is what stops a config value from reintroducing it.
|
|
117
|
+
test("never returns less than the server's freshness window", () => {
|
|
118
|
+
assert.equal(resolvePollIntervalMs("10"), MIN_POLL_INTERVAL_MS);
|
|
119
|
+
assert.equal(resolvePollIntervalMs("59"), MIN_POLL_INTERVAL_MS);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("matches the freshness window the fan-out gate uses", () => {
|
|
123
|
+
// Keep in step with MAILBOX_FRESHNESS_MS in
|
|
124
|
+
// packages/imap-worker/src/handlers/sync-mailboxes.ts.
|
|
125
|
+
assert.equal(MIN_POLL_INTERVAL_MS, 60_000);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("honours a configured interval above the floor", () => {
|
|
129
|
+
assert.equal(resolvePollIntervalMs("900"), 900_000);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("falls back to the default when unset or unparseable", () => {
|
|
133
|
+
assert.equal(resolvePollIntervalMs(undefined), 5 * 60 * 1000);
|
|
134
|
+
assert.equal(resolvePollIntervalMs("not-a-number"), 5 * 60 * 1000);
|
|
135
|
+
});
|
|
136
|
+
});
|
|
137
|
+
|
|
107
138
|
describe("hasPollIntervalElapsed", () => {
|
|
108
139
|
test("is false immediately after a poll", () => {
|
|
109
140
|
assert.equal(hasPollIntervalElapsed(NOW, NOW, 60_000), false);
|
|
@@ -25,16 +25,42 @@ const parsePositiveIntSeconds = (
|
|
|
25
25
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
26
26
|
};
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Floor for the poll interval, matching `MAILBOX_FRESHNESS_MS` in the
|
|
30
|
+
* imap-worker's sync-mailboxes fan-out.
|
|
31
|
+
*
|
|
32
|
+
* This poll uses POST /sync, the same endpoint as a person pressing refresh,
|
|
33
|
+
* and that endpoint's triggers skip the server's per-mailbox freshness gate.
|
|
34
|
+
* A timer is not a person: polling faster than the gate's own window would
|
|
35
|
+
* make every tick a full folder-by-folder re-enumeration for every open
|
|
36
|
+
* account — the fan-out storm the gate exists to prevent, reintroduced by a
|
|
37
|
+
* config value. `mailboxPollIntervalSeconds` can lengthen the interval, never
|
|
38
|
+
* shorten it past this: below the window there is nothing to gain, since no
|
|
39
|
+
* mailbox can have become stale in the meantime.
|
|
40
|
+
*/
|
|
41
|
+
export const MIN_POLL_INTERVAL_MS = 60 * 1000;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve the configured poll interval, never returning less than
|
|
45
|
+
* {@link MIN_POLL_INTERVAL_MS}.
|
|
46
|
+
*/
|
|
47
|
+
export const resolvePollIntervalMs = (
|
|
48
|
+
configuredSeconds: string | undefined,
|
|
49
|
+
): number =>
|
|
50
|
+
Math.max(
|
|
51
|
+
parsePositiveIntSeconds(configuredSeconds, DEFAULT_POLL_INTERVAL_SECONDS) *
|
|
52
|
+
1000,
|
|
53
|
+
MIN_POLL_INTERVAL_MS,
|
|
54
|
+
);
|
|
55
|
+
|
|
28
56
|
/**
|
|
29
57
|
* Client-side online-poll interval (#1251): while an account's mail is open,
|
|
30
58
|
* the tab re-triggers the same sync the pull-to-refresh path uses on this
|
|
31
59
|
* cadence — the replacement for the server's removed "online tier".
|
|
32
60
|
*/
|
|
33
|
-
export const POLL_INTERVAL_MS =
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
DEFAULT_POLL_INTERVAL_SECONDS,
|
|
37
|
-
) * 1000;
|
|
61
|
+
export const POLL_INTERVAL_MS = resolvePollIntervalMs(
|
|
62
|
+
getRuntimeConfig().mailboxPollIntervalSeconds,
|
|
63
|
+
);
|
|
38
64
|
|
|
39
65
|
/**
|
|
40
66
|
* Bounded concurrency for the per-poll fan-out across open accounts —
|
package/src/lib/drafts.ts
CHANGED
|
@@ -64,8 +64,7 @@ export function toImapDraftRowData(
|
|
|
64
64
|
timeLabel: formatEmailDate(thread.sentDate),
|
|
65
65
|
isRead: thread.isRead,
|
|
66
66
|
hasAttachment: thread.hasAttachment,
|
|
67
|
-
starred:
|
|
68
|
-
thread.star != null && thread.star !== "none" && thread.hasStars === true,
|
|
67
|
+
starred: thread.hasStars === true,
|
|
69
68
|
};
|
|
70
69
|
}
|
|
71
70
|
|
|
@@ -0,0 +1,71 @@
|
|
|
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 { dedupeByThread } from "./starred-rows.js";
|
|
5
|
+
|
|
6
|
+
// The starred listing returns one row per mailbox. The same mail filed in two
|
|
7
|
+
// folders is two rows sharing a threadId, both starred server-side, so a
|
|
8
|
+
// conversation would otherwise render twice.
|
|
9
|
+
|
|
10
|
+
const row = (
|
|
11
|
+
threadId: string,
|
|
12
|
+
messageId: string,
|
|
13
|
+
mailboxId: string,
|
|
14
|
+
): RemitImapThreadMessageResponse =>
|
|
15
|
+
({
|
|
16
|
+
threadId,
|
|
17
|
+
messageId,
|
|
18
|
+
mailboxId,
|
|
19
|
+
}) as unknown as RemitImapThreadMessageResponse;
|
|
20
|
+
|
|
21
|
+
describe("dedupeByThread", () => {
|
|
22
|
+
test("collapses two copies of one conversation to a single row", () => {
|
|
23
|
+
const result = dedupeByThread([
|
|
24
|
+
row("t1", "m-inbox", "inbox"),
|
|
25
|
+
row("t1", "m-archive", "archive"),
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
assert.deepEqual(
|
|
29
|
+
result.map((r) => r.messageId),
|
|
30
|
+
["m-inbox"],
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("keeps the first row, which is the newest under descending order", () => {
|
|
35
|
+
const result = dedupeByThread([
|
|
36
|
+
row("t1", "m-newest", "inbox"),
|
|
37
|
+
row("t1", "m-older", "archive"),
|
|
38
|
+
]);
|
|
39
|
+
|
|
40
|
+
assert.equal(result[0]?.messageId, "m-newest");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("leaves distinct conversations alone and preserves order", () => {
|
|
44
|
+
const result = dedupeByThread([
|
|
45
|
+
row("t1", "m1", "inbox"),
|
|
46
|
+
row("t2", "m2", "inbox"),
|
|
47
|
+
row("t3", "m3", "inbox"),
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
assert.deepEqual(
|
|
51
|
+
result.map((r) => r.threadId),
|
|
52
|
+
["t1", "t2", "t3"],
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("collapses copies that arrived on different pages", () => {
|
|
57
|
+
const pageOne = [row("t1", "m1", "inbox"), row("t2", "m2", "inbox")];
|
|
58
|
+
const pageTwo = [row("t2", "m2-copy", "archive"), row("t3", "m3", "inbox")];
|
|
59
|
+
|
|
60
|
+
const result = dedupeByThread([...pageOne, ...pageTwo]);
|
|
61
|
+
|
|
62
|
+
assert.deepEqual(
|
|
63
|
+
result.map((r) => r.threadId),
|
|
64
|
+
["t1", "t2", "t3"],
|
|
65
|
+
);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test("an empty list stays empty", () => {
|
|
69
|
+
assert.deepEqual(dedupeByThread([]), []);
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Collapse rows that are the same conversation.
|
|
5
|
+
*
|
|
6
|
+
* A message's identity includes its mailbox, so the same mail filed in two
|
|
7
|
+
* folders is two rows carrying the same server-side star. The starred scope
|
|
8
|
+
* already excludes the folder that causes this wholesale (Gmail's All Mail),
|
|
9
|
+
* but an ordinary copy in a user folder still produces a pair.
|
|
10
|
+
*
|
|
11
|
+
* Deduping over the accumulated pages rather than inside one keeps a
|
|
12
|
+
* conversation single even when its copies straddle a page boundary — a single
|
|
13
|
+
* page cannot know what earlier pages already showed. The first row wins, which
|
|
14
|
+
* is the newest under the server's descending order.
|
|
15
|
+
*/
|
|
16
|
+
export const dedupeByThread = (
|
|
17
|
+
items: RemitImapThreadMessageResponse[],
|
|
18
|
+
): RemitImapThreadMessageResponse[] => {
|
|
19
|
+
const seen = new Set<string>();
|
|
20
|
+
return items.filter((item) => {
|
|
21
|
+
if (seen.has(item.threadId)) return false;
|
|
22
|
+
seen.add(item.threadId);
|
|
23
|
+
return true;
|
|
24
|
+
});
|
|
25
|
+
};
|