@remit/web-client 0.0.9 → 0.0.11

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.9",
3
+ "version": "0.0.11",
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": {
@@ -163,7 +163,7 @@ export const ConversationView = ({
163
163
  } = useQuery({
164
164
  ...threadDetailOperationsListThreadMessagesOptions({
165
165
  path: { threadId },
166
- query: { order: "desc", mailboxId },
166
+ query: { order: "desc" },
167
167
  }),
168
168
  });
169
169
 
@@ -266,6 +266,7 @@ export const ConversationView = ({
266
266
  } = useToggleStar({
267
267
  threadId,
268
268
  mailboxId,
269
+ messages,
269
270
  });
270
271
 
271
272
  // Compose state for inline reply/forward.
@@ -6,7 +6,7 @@ import {
6
6
  configOperationsGetConfigQueryKey,
7
7
  } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
8
8
  import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
9
- import { Button, Input, Select, securityToApi } from "@remit/ui";
9
+ import { Button, Input, Select, SlidePanel, securityToApi } from "@remit/ui";
10
10
  import { useMutation, useQueryClient } from "@tanstack/react-query";
11
11
  import { Check, Loader2, X } from "lucide-react";
12
12
  import { useCallback, useEffect, useRef, useState } from "react";
@@ -19,7 +19,6 @@ import {
19
19
  type ProviderPreset,
20
20
  } from "../../lib/provider-presets.js";
21
21
  import { cn } from "../../lib/utils";
22
- import { SlidePanel } from "../ui/SlidePanel";
23
22
  import {
24
23
  appendAppPasswordHint,
25
24
  computeSmtpAutoFill,
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { selectMessagesToMarkRead } from "./useMarkAsRead.js";
4
+ import {
5
+ resolveMailboxesForMessages,
6
+ selectMessagesToMarkRead,
7
+ } from "./useMarkAsRead.js";
5
8
 
6
9
  const make = (
7
10
  overrides: Partial<RemitImapThreadMessageResponse> & {
@@ -163,3 +166,52 @@ describe("selectMessagesToMarkRead", () => {
163
166
  assert.deepStrictEqual(got, ["m-older"]);
164
167
  });
165
168
  });
169
+
170
+ describe("resolveMailboxesForMessages", () => {
171
+ const messages = [
172
+ make({
173
+ messageId: "m-received",
174
+ threadMessageId: "tm1",
175
+ isRead: false,
176
+ mailboxId: "mb-inbox",
177
+ }),
178
+ make({
179
+ messageId: "m-sent",
180
+ threadMessageId: "tm2",
181
+ isRead: false,
182
+ mailboxId: "mb-sent",
183
+ }),
184
+ ];
185
+
186
+ test("names every mailbox the batch touches, not the browsed one", () => {
187
+ const got = resolveMailboxesForMessages(
188
+ ["m-received", "m-sent"],
189
+ messages,
190
+ "mb-inbox",
191
+ );
192
+ assert.deepStrictEqual([...got].sort(), ["mb-inbox", "mb-sent"]);
193
+ });
194
+
195
+ test("marking only the sent reply leaves the browsed mailbox alone", () => {
196
+ const got = resolveMailboxesForMessages(["m-sent"], messages, "mb-inbox");
197
+ assert.deepStrictEqual(got, ["mb-sent"]);
198
+ });
199
+
200
+ test("names each mailbox once however many messages it holds", () => {
201
+ const got = resolveMailboxesForMessages(
202
+ ["m-received", "m-received"],
203
+ messages,
204
+ "mb-inbox",
205
+ );
206
+ assert.deepStrictEqual(got, ["mb-inbox"]);
207
+ });
208
+
209
+ test("falls back to the browsed mailbox for an unknown message", () => {
210
+ const got = resolveMailboxesForMessages(
211
+ ["m-elsewhere"],
212
+ messages,
213
+ "mb-inbox",
214
+ );
215
+ assert.deepStrictEqual(got, ["mb-inbox"]);
216
+ });
217
+ });
@@ -41,12 +41,35 @@ interface SnapshotEntry<T> {
41
41
 
42
42
  interface MarkAsReadContext {
43
43
  threadMessagesPrefix: readonly unknown[];
44
- threadsListPrefix: readonly unknown[];
45
- threadsSearchPrefix: readonly unknown[];
44
+ threadsListPrefixes: ReadonlyArray<readonly unknown[]>;
45
+ threadsSearchPrefixes: ReadonlyArray<readonly unknown[]>;
46
46
  previousThreadMessages: SnapshotEntry<ThreadMessagesData>[];
47
47
  previousThreadsList: SnapshotEntry<ThreadsListData>[];
48
48
  }
49
49
 
50
+ /**
51
+ * The mailboxes whose cached listings a batch of message mutations affects.
52
+ *
53
+ * A conversation spans mailboxes (#46), so marking a thread read can touch a
54
+ * received message in INBOX and the user's own reply in Sent at once, and each
55
+ * one's lists live under its own mailbox key. Falls back to the browsed mailbox
56
+ * for ids the thread's messages do not cover.
57
+ */
58
+ export const resolveMailboxesForMessages = (
59
+ messageIds: Iterable<string>,
60
+ messages: RemitImapThreadMessageResponse[],
61
+ fallbackMailboxId: string,
62
+ ): string[] => {
63
+ const byMessageId = new Map(
64
+ messages.map((message) => [message.messageId, message.mailboxId]),
65
+ );
66
+ const mailboxIds = new Set<string>();
67
+ for (const messageId of messageIds) {
68
+ mailboxIds.add(byMessageId.get(messageId) ?? fallbackMailboxId);
69
+ }
70
+ return [...mailboxIds];
71
+ };
72
+
50
73
  const setReadOnItems = (
51
74
  items: RemitImapThreadMessageResponse[],
52
75
  messageIds: Set<string>,
@@ -101,17 +124,26 @@ export const useMarkAsRead = ({
101
124
  threadDetailOperationsListThreadMessagesQueryKey({
102
125
  path: { threadId },
103
126
  });
104
- const threadsListPrefix = threadOperationsListThreadsQueryKey({
105
- path: { mailboxId },
106
- });
107
- const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
108
- path: { mailboxId },
109
- });
127
+ const affectedMailboxIds = resolveMailboxesForMessages(
128
+ messageIds,
129
+ messages,
130
+ mailboxId,
131
+ );
132
+ const threadsListPrefixes = affectedMailboxIds.map((id) =>
133
+ threadOperationsListThreadsQueryKey({ path: { mailboxId: id } }),
134
+ );
135
+ const threadsSearchPrefixes = affectedMailboxIds.map((id) =>
136
+ threadOperationsSearchThreadsQueryKey({ path: { mailboxId: id } }),
137
+ );
110
138
 
111
139
  await Promise.all([
112
140
  queryClient.cancelQueries({ queryKey: threadMessagesPrefix }),
113
- queryClient.cancelQueries({ queryKey: threadsListPrefix }),
114
- queryClient.cancelQueries({ queryKey: threadsSearchPrefix }),
141
+ ...threadsListPrefixes.map((queryKey) =>
142
+ queryClient.cancelQueries({ queryKey }),
143
+ ),
144
+ ...threadsSearchPrefixes.map((queryKey) =>
145
+ queryClient.cancelQueries({ queryKey }),
146
+ ),
115
147
  ]);
116
148
 
117
149
  const previousThreadMessages = queryClient
@@ -122,12 +154,12 @@ export const useMarkAsRead = ({
122
154
  )
123
155
  .map(([queryKey, data]) => ({ queryKey, data }));
124
156
 
125
- const previousThreadsList = queryClient
126
- .getQueriesData<ThreadsListData>({ queryKey: threadsListPrefix })
127
- .concat(
128
- queryClient.getQueriesData<ThreadsListData>({
129
- queryKey: threadsSearchPrefix,
130
- }),
157
+ const previousThreadsList = [
158
+ ...threadsListPrefixes,
159
+ ...threadsSearchPrefixes,
160
+ ]
161
+ .flatMap((queryKey) =>
162
+ queryClient.getQueriesData<ThreadsListData>({ queryKey }),
131
163
  )
132
164
  .filter(
133
165
  (entry): entry is [readonly unknown[], ThreadsListData] =>
@@ -157,19 +189,20 @@ export const useMarkAsRead = ({
157
189
  };
158
190
  };
159
191
 
160
- queryClient.setQueriesData<ThreadsListData>(
161
- { queryKey: threadsListPrefix },
162
- patchListData,
163
- );
164
- queryClient.setQueriesData<ThreadsListData>(
165
- { queryKey: threadsSearchPrefix },
166
- patchListData,
167
- );
192
+ for (const queryKey of [
193
+ ...threadsListPrefixes,
194
+ ...threadsSearchPrefixes,
195
+ ]) {
196
+ queryClient.setQueriesData<ThreadsListData>(
197
+ { queryKey },
198
+ patchListData,
199
+ );
200
+ }
168
201
 
169
202
  return {
170
203
  threadMessagesPrefix,
171
- threadsListPrefix,
172
- threadsSearchPrefix,
204
+ threadsListPrefixes,
205
+ threadsSearchPrefixes,
173
206
  previousThreadMessages,
174
207
  previousThreadsList,
175
208
  };
@@ -203,8 +236,12 @@ export const useMarkAsRead = ({
203
236
  onSettled: (_data, _err, _vars, context) => {
204
237
  if (!context) return;
205
238
  queryClient.invalidateQueries({ queryKey: context.threadMessagesPrefix });
206
- queryClient.invalidateQueries({ queryKey: context.threadsListPrefix });
207
- queryClient.invalidateQueries({ queryKey: context.threadsSearchPrefix });
239
+ for (const queryKey of [
240
+ ...context.threadsListPrefixes,
241
+ ...context.threadsSearchPrefixes,
242
+ ]) {
243
+ queryClient.invalidateQueries({ queryKey });
244
+ }
208
245
  // Refresh the sidebar mailbox list so the unread badge picks up the
209
246
  // next backend `unseenCount` (which is owned by IMAP sync).
210
247
  if (accountId) {
@@ -1,7 +1,10 @@
1
1
  import assert from "node:assert";
2
2
  import { describe, test } from "node:test";
3
3
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
4
- import { toggleStarsInItems } from "./useToggleStar.js";
4
+ import {
5
+ resolveMailboxForMessage,
6
+ toggleStarsInItems,
7
+ } from "./useToggleStar.js";
5
8
 
6
9
  const make = (
7
10
  messageId: string,
@@ -55,3 +58,38 @@ describe("toggleStarsInItems", () => {
55
58
  );
56
59
  });
57
60
  });
61
+
62
+ describe("resolveMailboxForMessage", () => {
63
+ const messages = [
64
+ { ...make("m-received", false), mailboxId: "mb-inbox" },
65
+ { ...make("m-sent", false), mailboxId: "mb-sent" },
66
+ ];
67
+
68
+ test("starring a sent reply patches the sent mailbox, not the browsed one", () => {
69
+ assert.strictEqual(
70
+ resolveMailboxForMessage("m-sent", messages, "mb-inbox"),
71
+ "mb-sent",
72
+ );
73
+ });
74
+
75
+ test("starring a message in the browsed mailbox is unchanged", () => {
76
+ assert.strictEqual(
77
+ resolveMailboxForMessage("m-received", messages, "mb-inbox"),
78
+ "mb-inbox",
79
+ );
80
+ });
81
+
82
+ test("falls back to the browsed mailbox when the thread is unknown", () => {
83
+ assert.strictEqual(
84
+ resolveMailboxForMessage("m-sent", undefined, "mb-inbox"),
85
+ "mb-inbox",
86
+ );
87
+ });
88
+
89
+ test("falls back to the browsed mailbox for a message not in the thread", () => {
90
+ assert.strictEqual(
91
+ resolveMailboxForMessage("m-elsewhere", messages, "mb-inbox"),
92
+ "mb-inbox",
93
+ );
94
+ });
95
+ });
@@ -12,9 +12,35 @@ import { formatErrorDetail } from "@/components/ui/error-banners";
12
12
 
13
13
  interface UseToggleStarOptions {
14
14
  threadId: string;
15
+ /**
16
+ * The mailbox whose lists to patch when the starred message is not in
17
+ * `messages` — the browsed mailbox, which is where a list row lives.
18
+ */
15
19
  mailboxId: string;
20
+ /**
21
+ * The thread's messages, when the caller has them. A conversation spans
22
+ * mailboxes (#46), so the lists to patch are the ones for the mailbox the
23
+ * starred message is actually in, not the one being browsed.
24
+ */
25
+ messages?: RemitImapThreadMessageResponse[];
16
26
  }
17
27
 
28
+ /**
29
+ * The mailbox whose cached listings a mutation on `messageId` affects.
30
+ *
31
+ * The message's own mailbox, falling back to the browsed one when the thread's
32
+ * messages are unknown or do not contain it. Starring a reply in Sent from a
33
+ * conversation opened in INBOX has to patch Sent's lists; INBOX's do not hold
34
+ * that message and patching them changes nothing.
35
+ */
36
+ export const resolveMailboxForMessage = (
37
+ messageId: string,
38
+ messages: RemitImapThreadMessageResponse[] | undefined,
39
+ fallbackMailboxId: string,
40
+ ): string =>
41
+ messages?.find((message) => message.messageId === messageId)?.mailboxId ??
42
+ fallbackMailboxId;
43
+
18
44
  interface ThreadMessagesData {
19
45
  items: RemitImapThreadMessageResponse[];
20
46
  [key: string]: unknown;
@@ -57,6 +83,7 @@ export const toggleStarsInItems = (
57
83
  export const useToggleStar = ({
58
84
  threadId,
59
85
  mailboxId,
86
+ messages,
60
87
  }: UseToggleStarOptions) => {
61
88
  const queryClient = useQueryClient();
62
89
  const { pushError } = useErrorBanners();
@@ -75,11 +102,16 @@ export const useToggleStar = ({
75
102
  threadDetailOperationsListThreadMessagesQueryKey({
76
103
  path: { threadId },
77
104
  });
105
+ const affectedMailboxId = resolveMailboxForMessage(
106
+ messageId,
107
+ messages,
108
+ mailboxId,
109
+ );
78
110
  const threadsListPrefix = threadOperationsListThreadsQueryKey({
79
- path: { mailboxId },
111
+ path: { mailboxId: affectedMailboxId },
80
112
  });
81
113
  const threadsSearchPrefix = threadOperationsSearchThreadsQueryKey({
82
- path: { mailboxId },
114
+ path: { mailboxId: affectedMailboxId },
83
115
  });
84
116
  // The unified cross-account listing backs the daily brief and the
85
117
  // Starred mailbox, so a star toggled from an inbox has to land there
package/src/index.css CHANGED
@@ -4,9 +4,18 @@
4
4
  the .dark ancestor scope used everywhere in this app. */
5
5
  @import "@remit/ui/tokens.css";
6
6
 
7
- /* Tailwind v4 content scanning: point at the source Tailwind must inspect
8
- for utility class usage (auto-detection misses linked workspace packages). */
7
+ /* Tailwind v4 content scanning: name every source Tailwind must inspect for
8
+ utility class usage. Automatic detection is rooted at the Vite root, which
9
+ for the distributor harness build is a throwaway directory holding only the
10
+ entry — so nothing here may rely on it. Both this package's own components
11
+ and the linked workspace UI package are declared explicitly.
12
+
13
+ Tests are excluded: they assert on class strings, and a scanned assertion
14
+ emits the utility it names into the shipped stylesheet. */
15
+ @source "./**/*.{ts,tsx}";
9
16
  @source "../../ui/src/**/*.{ts,tsx}";
17
+ @source not "./**/*.test.{ts,tsx}";
18
+ @source not "../../ui/src/**/*.test.{ts,tsx}";
10
19
 
11
20
  /* Hide scrollbars while maintaining scroll functionality */
12
21
  .overflow-y-auto {
@@ -0,0 +1,97 @@
1
+ import assert from "node:assert/strict";
2
+ import { readdirSync, readFileSync } from "node:fs";
3
+ import { dirname, relative, resolve, sep } from "node:path";
4
+ import { describe, it } from "node:test";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ /**
8
+ * Tailwind v4 roots automatic source detection at the bundler's root. The
9
+ * distributor harness builds from a throwaway root holding only the entry, so
10
+ * nothing this app renders is discovered automatically: every directory whose
11
+ * components carry utility classes has to be named by an `@source` rule. When
12
+ * one is missing the build still succeeds — it just ships a stylesheet with
13
+ * those utilities absent, and the screens using them lay out as unstyled boxes
14
+ * (#57: the settings slide-over covered the whole viewport).
15
+ */
16
+
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+ const cssPath = resolve(here, "index.css");
19
+ const css = readFileSync(cssPath, "utf8");
20
+
21
+ /** Globs Tailwind scans, and globs it is told to skip. */
22
+ const globs = (kind: "include" | "exclude"): string[] =>
23
+ [...css.matchAll(/@source\s+(not\s+)?"([^"]+)"/g)]
24
+ .filter(([, negated]) => (kind === "exclude") === Boolean(negated))
25
+ .map(([, , glob]) => glob);
26
+
27
+ /** The directory an `@source` glob starts scanning from. */
28
+ const sourceRoots = (): string[] =>
29
+ globs("include").map((glob) => {
30
+ const literal = glob.split("*")[0] ?? glob;
31
+ return resolve(dirname(cssPath), literal);
32
+ });
33
+
34
+ /** Every directory below `root` that holds a component file. */
35
+ const componentDirs = (root: string): string[] => {
36
+ const found: string[] = [];
37
+ const walk = (dir: string): void => {
38
+ const entries = readdirSync(dir, { withFileTypes: true });
39
+ if (entries.some((e) => e.isFile() && e.name.endsWith(".tsx"))) {
40
+ found.push(dir);
41
+ }
42
+ for (const entry of entries) {
43
+ if (entry.isDirectory() && entry.name !== "node_modules") {
44
+ walk(resolve(dir, entry.name));
45
+ }
46
+ }
47
+ };
48
+ walk(root);
49
+ return found;
50
+ };
51
+
52
+ const isCovered = (dir: string, roots: string[]): boolean =>
53
+ roots.some((root) => {
54
+ const rel = relative(root, dir);
55
+ return rel === "" || (!rel.startsWith("..") && !rel.startsWith(sep));
56
+ });
57
+
58
+ describe("tailwind source coverage (#57)", () => {
59
+ const roots = sourceRoots();
60
+
61
+ it("declares at least one source", () => {
62
+ assert.ok(roots.length > 0);
63
+ });
64
+
65
+ it("covers every directory in this package that renders components", () => {
66
+ const uncovered = componentDirs(here).filter((d) => !isCovered(d, roots));
67
+ assert.deepEqual(
68
+ uncovered.map((d) => relative(here, d)),
69
+ [],
70
+ );
71
+ });
72
+
73
+ it("covers the linked design system package", () => {
74
+ const ui = resolve(here, "../../ui/src");
75
+ const uncovered = componentDirs(ui).filter((d) => !isCovered(d, roots));
76
+ assert.deepEqual(
77
+ uncovered.map((d) => relative(ui, d)),
78
+ [],
79
+ );
80
+ });
81
+
82
+ /**
83
+ * A test asserting on a class string is a class string in a scanned file, so
84
+ * without this every utility a test names is emitted into the shipped CSS.
85
+ */
86
+ it("excludes test files from every source it scans", () => {
87
+ const uncovered = globs("include").filter(
88
+ (glob) =>
89
+ !globs("exclude").some(
90
+ (excluded) =>
91
+ excluded ===
92
+ glob.replace(/\/\*\*\/\*\.\{ts,tsx\}$/, "/**/*.test.{ts,tsx}"),
93
+ ),
94
+ );
95
+ assert.deepEqual(uncovered, []);
96
+ });
97
+ });
@@ -1,72 +0,0 @@
1
- import { X } from "lucide-react";
2
- import type { ReactNode } from "react";
3
- import { cn } from "../../lib/utils";
4
-
5
- interface SlidePanelProps {
6
- isOpen: boolean;
7
- onClose: () => void;
8
- title: string;
9
- children: ReactNode;
10
- footer?: ReactNode;
11
- }
12
-
13
- export const SlidePanel = ({
14
- isOpen,
15
- onClose,
16
- title,
17
- children,
18
- footer,
19
- }: SlidePanelProps) => (
20
- <>
21
- {/* Backdrop overlay */}
22
- {/* biome-ignore lint/a11y/useSemanticElements: full-screen backdrop overlay; <button> default styles would break fixed inset positioning */}
23
- <div
24
- className={cn(
25
- "fixed inset-0 bg-black/30 z-40 transition-opacity",
26
- isOpen ? "opacity-100" : "opacity-0 pointer-events-none",
27
- )}
28
- onClick={onClose}
29
- onKeyDown={(e) => e.key === "Escape" && onClose()}
30
- role="button"
31
- tabIndex={0}
32
- aria-label="Close panel"
33
- />
34
-
35
- {/* Slide-in panel */}
36
- <div
37
- className={cn(
38
- "fixed top-0 right-0 h-full w-full sm:w-[400px] sm:max-w-[90vw] bg-canvas border-l border-line shadow-xl z-50",
39
- "transform transition-transform duration-200 ease-out",
40
- isOpen ? "translate-x-0" : "translate-x-full",
41
- )}
42
- role="dialog"
43
- aria-modal="true"
44
- aria-labelledby="slide-panel-title"
45
- >
46
- {/* Panel header */}
47
- <div className="flex items-center justify-between px-4 h-14 border-b border-line">
48
- <h2 id="slide-panel-title" className="font-semibold">
49
- {title}
50
- </h2>
51
- <button
52
- type="button"
53
- onClick={onClose}
54
- className="p-1.5 rounded-md hover:bg-surface-raised transition-colors"
55
- aria-label="Close"
56
- >
57
- <X className="size-5" />
58
- </button>
59
- </div>
60
-
61
- {/* Panel content */}
62
- <div className="h-[calc(100%-3.5rem)] flex flex-col">
63
- <div className="flex-1 overflow-auto p-4">{children}</div>
64
- {footer && (
65
- <div className="flex justify-end gap-3 p-4 border-t border-line bg-canvas">
66
- {footer}
67
- </div>
68
- )}
69
- </div>
70
- </div>
71
- </>
72
- );