@remit/web-client 0.0.137 → 0.0.139

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.137",
3
+ "version": "0.0.139",
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": {
@@ -29,7 +29,7 @@
29
29
  "build:dist": "npm run generate:routes && node --import tsx harness/build.mjs",
30
30
  "preview": "vite preview",
31
31
  "test:typecheck": "npm run generate:routes && tsgo --noEmit && tsgo --noEmit -p tsconfig.node.json",
32
- "test:run": "node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
32
+ "test:run": "TSX_TSCONFIG_PATH=./tsconfig.test.json node --import tsx --import ./test-support/register.mjs --experimental-test-coverage --test-coverage-include='src/**' --test-coverage-exclude='src/**/*.test.ts' --test-coverage-exclude='src/**/*.test.tsx' --test-coverage-exclude='src/test-support/**' --test-coverage-lines=86 --test 'src/**/*.test.ts'",
33
33
  "test": "npm run test:typecheck && npm run test:run"
34
34
  },
35
35
  "peerDependencies": {
@@ -1,19 +1,10 @@
1
1
  import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
- import type { RemitImapAddressResponse } from "@remit/api-http-client/types.gen.ts";
3
- import {
4
- AddressTag,
5
- type Suggestion,
6
- SuggestList,
7
- useSuggestList,
8
- } from "@remit/ui";
2
+ import { type AddressEntry, ComposeAddressField } from "@remit/ui";
9
3
  import { useQuery } from "@tanstack/react-query";
10
- import { useCallback, useMemo, useRef, useState } from "react";
4
+ import { useMemo, useState } from "react";
11
5
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
12
6
 
13
- export interface AddressEntry {
14
- email: string;
15
- displayName?: string;
16
- }
7
+ export type { AddressEntry };
17
8
 
18
9
  interface AddressFieldProps {
19
10
  label: string;
@@ -22,199 +13,40 @@ interface AddressFieldProps {
22
13
  placeholder?: string;
23
14
  }
24
15
 
25
- /** Beyond Enter, the keys that take the highlighted suggestion in a chips field. */
26
- const ACCEPT_KEYS = ["Tab", ","] as const;
27
-
28
- const isValidEmail = (value: string): boolean =>
29
- /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value.trim());
30
-
31
- const parseEmailInput = (value: string): AddressEntry | undefined => {
32
- const trimmed = value.trim();
33
- if (!trimmed) return undefined;
34
-
35
- const angleMatch = trimmed.match(/^(.+?)\s*<([^>]+)>$/);
36
- if (angleMatch) {
37
- const displayName = angleMatch[1].trim();
38
- const email = angleMatch[2].trim();
39
- if (isValidEmail(email)) return { email, displayName };
40
- }
41
-
42
- if (isValidEmail(trimmed)) return { email: trimmed };
43
- return undefined;
44
- };
45
-
16
+ /** Looks the account's known correspondents up for the field to offer. */
46
17
  export const AddressField = ({
47
18
  label,
48
19
  addresses,
49
20
  onChange,
50
21
  placeholder,
51
22
  }: AddressFieldProps) => {
52
- const [inputValue, setInputValue] = useState("");
53
- const inputRef = useRef<HTMLInputElement>(null);
54
-
55
- const debouncedQuery = useDebouncedValue(inputValue, 200);
23
+ const [query, setQuery] = useState("");
24
+ const debouncedQuery = useDebouncedValue(query, 200);
56
25
 
57
- const { data: suggestionsData } = useQuery({
26
+ const { data } = useQuery({
58
27
  ...addressOperationsSearchAddressesOptions({
59
28
  query: { q: debouncedQuery, limit: 8 },
60
29
  }),
61
30
  enabled: debouncedQuery.length >= 2,
62
31
  });
63
32
 
64
- const suggestions = suggestionsData?.items ?? [];
65
- const existingEmails = new Set(addresses.map((a) => a.email.toLowerCase()));
66
- const filteredSuggestions =
67
- inputValue.length >= 2
68
- ? suggestions.filter(
69
- (s) => !existingEmails.has(s.normalizedEmail.toLowerCase()),
70
- )
71
- : [];
72
-
73
- const addAddress = useCallback(
74
- (entry: AddressEntry) => {
75
- if (existingEmails.has(entry.email.toLowerCase())) return;
76
- onChange([...addresses, entry]);
77
- setInputValue("");
78
- },
79
- [addresses, existingEmails, onChange],
80
- );
81
-
82
- const removeAddress = useCallback(
83
- (index: number) => {
84
- onChange(addresses.filter((_, i) => i !== index));
85
- },
86
- [addresses, onChange],
87
- );
88
-
89
- const selectSuggestion = useCallback(
90
- (suggestion: RemitImapAddressResponse) => {
91
- addAddress({
92
- email: suggestion.normalizedEmail,
93
- displayName: suggestion.displayName,
94
- });
95
- inputRef.current?.focus();
96
- },
97
- [addAddress],
98
- );
99
-
100
- const commitInput = useCallback(() => {
101
- const entry = parseEmailInput(inputValue);
102
- if (entry) {
103
- addAddress(entry);
104
- }
105
- }, [inputValue, addAddress]);
106
-
107
- // The open state, the highlight, and the arrow/Enter/Escape handling are the
108
- // app's one typeahead behaviour, shared with the filter-rule value field.
109
- const suggest = useSuggestList({
110
- count: filteredSuggestions.length,
111
- acceptKeys: ACCEPT_KEYS,
112
- onAccept: (index) => selectSuggestion(filteredSuggestions[index]),
113
- });
114
-
115
- const options = useMemo<Suggestion[]>(
33
+ const suggestions = useMemo<AddressEntry[]>(
116
34
  () =>
117
- filteredSuggestions.map((suggestion) => ({
118
- value: suggestion.normalizedEmail,
119
- label: suggestion.displayName ?? suggestion.normalizedEmail,
120
- ...(suggestion.displayName ? { hint: suggestion.normalizedEmail } : {}),
35
+ (data?.items ?? []).map((item) => ({
36
+ email: item.normalizedEmail,
37
+ displayName: item.displayName,
121
38
  })),
122
- [filteredSuggestions],
39
+ [data],
123
40
  );
124
41
 
125
- const handleKeyDown = useCallback(
126
- (e: React.KeyboardEvent<HTMLInputElement>) => {
127
- if (e.key === "Backspace" && inputValue === "" && addresses.length > 0) {
128
- removeAddress(addresses.length - 1);
129
- return;
130
- }
131
-
132
- if (suggest.handleKeyDown(e)) return;
133
-
134
- if (e.key === "Enter" || e.key === "Tab" || e.key === ",") {
135
- if (inputValue.trim()) {
136
- e.preventDefault();
137
- commitInput();
138
- }
139
- }
140
- },
141
- [
142
- inputValue,
143
- addresses.length,
144
- removeAddress,
145
- suggest.handleKeyDown,
146
- commitInput,
147
- ],
148
- );
149
-
150
- const handleBlur = useCallback(() => {
151
- setTimeout(() => {
152
- commitInput();
153
- suggest.dismiss();
154
- }, 150);
155
- }, [commitInput, suggest.dismiss]);
156
-
157
42
  return (
158
- <div className="relative">
159
- <div className="flex items-start gap-2">
160
- <label
161
- htmlFor={`address-field-${label}`}
162
- className="text-sm text-fg-muted shrink-0 w-12 pt-1.5"
163
- >
164
- {label}:
165
- </label>
166
- {/* biome-ignore lint/a11y/noStaticElementInteractions: click-to-focus wrapper for the address input; keyboard is forwarded to the inner input */}
167
- <div
168
- className="flex-1 flex flex-wrap items-center gap-1 min-h-[36px] px-2 py-1 border rounded-md bg-canvas cursor-text"
169
- onClick={() => inputRef.current?.focus()}
170
- onKeyDown={(e) => {
171
- if (e.key === "Enter" || e.key === " ") inputRef.current?.focus();
172
- }}
173
- >
174
- {addresses.map((addr, i) => (
175
- <AddressTag
176
- key={addr.email}
177
- email={addr.email}
178
- displayName={addr.displayName}
179
- onRemove={() => removeAddress(i)}
180
- />
181
- ))}
182
- <input
183
- ref={inputRef}
184
- id={`address-field-${label}`}
185
- type="text"
186
- value={inputValue}
187
- onChange={(e) => {
188
- suggest.reopen();
189
- setInputValue(e.target.value);
190
- }}
191
- onKeyDown={handleKeyDown}
192
- onBlur={handleBlur}
193
- placeholder={addresses.length === 0 ? placeholder : ""}
194
- className="flex-1 min-w-[120px] bg-transparent outline-none text-sm py-0.5"
195
- autoComplete="off"
196
- {...suggest.comboboxProps}
197
- />
198
- </div>
199
- </div>
200
-
201
- {suggest.open && (
202
- <SuggestList
203
- id={suggest.listId}
204
- suggestions={options}
205
- activeIndex={suggest.activeIndex}
206
- optionId={suggest.optionId}
207
- onPick={(option) => {
208
- const picked = filteredSuggestions.find(
209
- (suggestion) => suggestion.normalizedEmail === option.value,
210
- );
211
- if (picked) selectSuggestion(picked);
212
- }}
213
- onHighlight={suggest.setActiveIndex}
214
- label={`${label} suggestions`}
215
- className="absolute left-12 right-0 z-50 mt-1 max-h-[200px] shadow-lg"
216
- />
217
- )}
218
- </div>
43
+ <ComposeAddressField
44
+ label={label}
45
+ addresses={addresses}
46
+ onChange={onChange}
47
+ placeholder={placeholder}
48
+ suggestions={suggestions}
49
+ onQueryChange={setQuery}
50
+ />
219
51
  );
220
52
  };
@@ -11,10 +11,17 @@ import type {
11
11
  import {
12
12
  ComposeActionBar,
13
13
  ComposeFormShell,
14
+ ComposeHeader,
15
+ ComposeSubjectField,
16
+ composeHeaderSummary,
17
+ defaultComposeLanguages,
14
18
  EMPTY_RICH_TEXT,
19
+ modeOfDraft,
15
20
  QuotedText,
16
21
  type RichTextValue,
17
22
  sanitizeQuotedHtml,
23
+ unwrapLanguage,
24
+ wrapWithLanguage,
18
25
  } from "@remit/ui";
19
26
  import type { ComposeBodyMode } from "@remit/ui/rich-text";
20
27
  import { useMutation, useQuery } from "@tanstack/react-query";
@@ -23,6 +30,7 @@ import {
23
30
  Suspense,
24
31
  useCallback,
25
32
  useEffect,
33
+ useMemo,
26
34
  useRef,
27
35
  useState,
28
36
  } from "react";
@@ -38,10 +46,9 @@ import {
38
46
  import type { AddressEntry } from "./AddressField";
39
47
  import { AddressField } from "./AddressField";
40
48
  import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
41
- import { modeOfDraft } from "./compose-mode";
42
49
 
43
50
  const LazyComposeBody = lazy(() =>
44
- import("./ComposeBody.js").then((m) => ({ default: m.ComposeBody })),
51
+ import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
45
52
  );
46
53
 
47
54
  const ComposeBodyFallback = () => (
@@ -56,7 +63,6 @@ import { useVisualViewport } from "../../hooks/useVisualViewport.js";
56
63
  import type { ComposeMode } from "./ComposeProvider";
57
64
  import { useCompose } from "./ComposeProvider";
58
65
  import { FromSelector } from "./FromSelector";
59
- import { SubjectField } from "./SubjectField";
60
66
 
61
67
  interface ComposeFormProps {
62
68
  mode: ComposeMode;
@@ -166,9 +172,15 @@ const getReferences = (
166
172
  const outgoingBody = (
167
173
  bodyMode: ComposeBodyMode,
168
174
  body: RichTextValue,
175
+ language: string,
169
176
  ): { textBody: string | undefined; htmlBody: string | undefined } => ({
170
177
  textBody: body.text || undefined,
171
- htmlBody: bodyMode === "plain" ? "" : body.html || undefined,
178
+ htmlBody:
179
+ bodyMode === "plain"
180
+ ? ""
181
+ : body.html
182
+ ? wrapWithLanguage(body.html, language)
183
+ : undefined,
172
184
  });
173
185
 
174
186
  const isFormEmpty = (
@@ -185,10 +197,10 @@ const isFormEmpty = (
185
197
  body.text.trim() === "";
186
198
 
187
199
  // ---------------------------------------------------------------------------
188
- // ComposeHeadercollapsed on mobile when the software keyboard is open
200
+ // WiredComposeHeaderthe shared header, with the app's fields in its slots
189
201
  // ---------------------------------------------------------------------------
190
202
 
191
- interface ComposeHeaderProps {
203
+ interface WiredComposeHeaderProps {
192
204
  selectedAccountId?: string;
193
205
  onAccountChange: (account: RemitImapAccountResponse) => void;
194
206
  toAddresses: AddressEntry[];
@@ -205,7 +217,7 @@ interface ComposeHeaderProps {
205
217
  setSubject: (v: string) => void;
206
218
  }
207
219
 
208
- const ComposeHeader = ({
220
+ const WiredComposeHeader = ({
209
221
  selectedAccountId,
210
222
  onAccountChange,
211
223
  toAddresses,
@@ -220,93 +232,55 @@ const ComposeHeader = ({
220
232
  setShowBcc,
221
233
  subject,
222
234
  setSubject,
223
- }: ComposeHeaderProps) => {
235
+ }: WiredComposeHeaderProps) => {
224
236
  const isDesktop = useIsDesktop();
225
237
  const { isKeyboardOpen } = useVisualViewport();
226
- const collapsed = !isDesktop && isKeyboardOpen;
227
-
228
- if (collapsed) {
229
- // Compact single-line summary when the keyboard eats vertical space
230
- const chips: string[] = [];
231
- if (toAddresses.length > 0)
232
- chips.push(
233
- `To: ${toAddresses.map((a) => a.displayName ?? a.email).join(", ")}`,
234
- );
235
- if (ccAddresses.length > 0) chips.push(`Cc: ${ccAddresses.length}`);
236
- if (bccAddresses.length > 0) chips.push(`Bcc: ${bccAddresses.length}`);
237
- if (subject) chips.push(subject);
238
-
239
- return (
240
- <div
241
- className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
242
- data-testid="compose-header-collapsed"
243
- >
244
- <span className="truncate text-xs text-fg-muted">
245
- {chips.length > 0 ? chips.join(" · ") : "…"}
246
- </span>
247
- <span className="shrink-0 inline-flex items-center justify-center rounded bg-surface-sunken px-1.5 py-0.5 text-2xs text-fg-muted">
248
-
249
- </span>
250
- </div>
251
- );
252
- }
253
238
 
254
239
  return (
255
- <div className="space-y-1 px-3 py-2 border-b border-line">
256
- <FromSelector
257
- selectedAccountId={selectedAccountId}
258
- onSelect={onAccountChange}
259
- />
260
- <AddressField
261
- label="To"
262
- addresses={toAddresses}
263
- onChange={setToAddresses}
264
- placeholder="Recipients"
265
- />
266
- {showCc ? (
267
- <AddressField
268
- label="Cc"
269
- addresses={ccAddresses}
270
- onChange={setCcAddresses}
240
+ <ComposeHeader
241
+ collapsed={!isDesktop && isKeyboardOpen}
242
+ summary={composeHeaderSummary({
243
+ to: toAddresses,
244
+ cc: ccAddresses,
245
+ bcc: bccAddresses,
246
+ subject,
247
+ })}
248
+ from={
249
+ <FromSelector
250
+ selectedAccountId={selectedAccountId}
251
+ onSelect={onAccountChange}
271
252
  />
272
- ) : (
273
- <div className="flex gap-2 pl-14">
274
- <button
275
- type="button"
276
- onClick={() => setShowCc(true)}
277
- className="text-xs text-fg-muted hover:text-fg transition-colors"
278
- >
279
- Cc
280
- </button>
281
- <button
282
- type="button"
283
- onClick={() => setShowBcc(true)}
284
- className="text-xs text-fg-muted hover:text-fg transition-colors"
285
- >
286
- Bcc
287
- </button>
288
- </div>
289
- )}
290
- {showCc && !showBcc && (
291
- <div className="pl-14">
292
- <button
293
- type="button"
294
- onClick={() => setShowBcc(true)}
295
- className="text-xs text-fg-muted hover:text-fg transition-colors"
296
- >
297
- Bcc
298
- </button>
299
- </div>
300
- )}
301
- {showBcc && (
253
+ }
254
+ to={
302
255
  <AddressField
303
- label="Bcc"
304
- addresses={bccAddresses}
305
- onChange={setBccAddresses}
256
+ label="To"
257
+ addresses={toAddresses}
258
+ onChange={setToAddresses}
259
+ placeholder="Recipients"
306
260
  />
307
- )}
308
- <SubjectField value={subject} onChange={setSubject} />
309
- </div>
261
+ }
262
+ cc={
263
+ showCc ? (
264
+ <AddressField
265
+ label="Cc"
266
+ addresses={ccAddresses}
267
+ onChange={setCcAddresses}
268
+ />
269
+ ) : undefined
270
+ }
271
+ bcc={
272
+ showBcc ? (
273
+ <AddressField
274
+ label="Bcc"
275
+ addresses={bccAddresses}
276
+ onChange={setBccAddresses}
277
+ />
278
+ ) : undefined
279
+ }
280
+ subject={<ComposeSubjectField value={subject} onChange={setSubject} />}
281
+ onShowCc={() => setShowCc(true)}
282
+ onShowBcc={() => setShowBcc(true)}
283
+ />
310
284
  );
311
285
  };
312
286
 
@@ -356,6 +330,7 @@ export const ComposeForm = ({
356
330
  setInitialHtml("");
357
331
  setInitialText("");
358
332
  setBodyMode("rich");
333
+ setDraftLanguage(undefined);
359
334
  setBody(EMPTY_RICH_TEXT);
360
335
  setDocumentGeneration((generation) => generation + 1);
361
336
  setDraftLoaded(false);
@@ -372,6 +347,11 @@ export const ComposeForm = ({
372
347
  );
373
348
  const [initialText, setInitialText] = useState(signature.plainText);
374
349
  const [bodyMode, setBodyMode] = useState<ComposeBodyMode>("rich");
350
+ // What the body is tagged with on the way out. The composer owns the value —
351
+ // it is the surface that has the text detection reads — and reports it here,
352
+ // because this is where a draft is written and where a send is assembled.
353
+ const [composeLanguage, setComposeLanguage] = useState("en");
354
+ const [draftLanguage, setDraftLanguage] = useState<string | undefined>();
375
355
  const [body, setBody] = useState<RichTextValue>(() => ({
376
356
  html: buildInitialHtml(signature.plainText),
377
357
  text: signature.plainText,
@@ -414,9 +394,15 @@ export const ComposeForm = ({
414
394
  // is read off that rather than a field of its own. A rich draft comes back
415
395
  // from its HTML — reading its text into one paragraph, as this did, brought
416
396
  // a formatted message back flattened.
417
- const loadedHtml = draftData.htmlBody ?? "";
397
+ // A rich draft carries its language in the `<div lang>` it was stored
398
+ // under; the editor reopens on what is inside that, so a reopened draft
399
+ // does not gain a second wrapper on its next autosave. A plain draft has
400
+ // no HTML to have carried one, and comes back on the account default.
401
+ const stored = unwrapLanguage(draftData.htmlBody ?? "");
402
+ const loadedHtml = stored.html;
418
403
  const loadedText = draftData.textBody ?? "";
419
404
  setBodyMode(modeOfDraft(draftData.htmlBody));
405
+ setDraftLanguage(stored.language ?? undefined);
420
406
  setInitialHtml(loadedHtml);
421
407
  setInitialText(loadedText);
422
408
  setBody({ html: loadedHtml, text: loadedText, formatting: [] });
@@ -525,6 +511,17 @@ export const ComposeForm = ({
525
511
  ? accountIsMissingSmtp(selectedAccount)
526
512
  : false;
527
513
 
514
+ // An account that has never been to the language setting falls back to what
515
+ // the browser already knows the user reads, which is an ordered answer.
516
+ const configured = selectedAccount?.composeLanguages;
517
+ const accountLanguages = useMemo(
518
+ () =>
519
+ configured && configured.length > 0
520
+ ? configured
521
+ : defaultComposeLanguages(navigator.languages),
522
+ [configured],
523
+ );
524
+
528
525
  // The action bar refuses a second press while one is in flight, but the
529
526
  // editor's own Cmd+Enter goes straight to `handleSend`, and the write that
530
527
  // now precedes the request widens the window a second press lands in.
@@ -545,7 +542,11 @@ export const ComposeForm = ({
545
542
  if (isFormEmpty(toAddresses, ccAddresses, bccAddresses, subject, body))
546
543
  return;
547
544
 
548
- const { htmlBody, textBody } = outgoingBody(bodyMode, body);
545
+ const { htmlBody, textBody } = outgoingBody(
546
+ bodyMode,
547
+ body,
548
+ composeLanguage,
549
+ );
549
550
 
550
551
  saveDraft({
551
552
  accountId: selectedAccountId,
@@ -568,6 +569,7 @@ export const ComposeForm = ({
568
569
  subject,
569
570
  body,
570
571
  bodyMode,
572
+ composeLanguage,
571
573
  saveDraft,
572
574
  ]);
573
575
 
@@ -585,7 +587,11 @@ export const ComposeForm = ({
585
587
  ? getReferences(sourceMessage)
586
588
  : {};
587
589
 
588
- const { htmlBody, textBody } = outgoingBody(bodyMode, body);
590
+ const { htmlBody, textBody } = outgoingBody(
591
+ bodyMode,
592
+ body,
593
+ composeLanguage,
594
+ );
589
595
  const createdThisAttempt = !outboxMessageId;
590
596
 
591
597
  // The debounce dropped above may have been holding the last two seconds
@@ -653,6 +659,7 @@ export const ComposeForm = ({
653
659
  subject,
654
660
  body,
655
661
  bodyMode,
662
+ composeLanguage,
656
663
  mode,
657
664
  sourceMessage,
658
665
  outboxMessageId,
@@ -690,7 +697,7 @@ export const ComposeForm = ({
690
697
  ) : undefined
691
698
  }
692
699
  header={
693
- <ComposeHeader
700
+ <WiredComposeHeader
694
701
  selectedAccountId={selectedAccountId}
695
702
  onAccountChange={handleAccountChange}
696
703
  toAddresses={toAddresses}
@@ -746,6 +753,9 @@ export const ComposeForm = ({
746
753
  onSubmit={handleSend}
747
754
  autoFocus={mode === "new"}
748
755
  onConversionError={pushError}
756
+ languages={accountLanguages}
757
+ initialLanguage={draftLanguage}
758
+ onLanguageChange={setComposeLanguage}
749
759
  />
750
760
  </Suspense>
751
761
  </ComposeFormShell>
@@ -1,49 +1,24 @@
1
+ import { ComposeSmtpMissingBanner as Banner } from "@remit/ui";
1
2
  import { useNavigate } from "@tanstack/react-router";
2
- import { AlertTriangle, ArrowRight } from "lucide-react";
3
3
 
4
4
  interface ComposeSmtpMissingBannerProps {
5
5
  accountId: string;
6
6
  }
7
7
 
8
- /**
9
- * Non-dismissible banner shown above the compose form when the selected
10
- * account has no SMTP host configured. Pairs with disabling the Send
11
- * button so the user has a single, factual explanation of why sending
12
- * is blocked. See issue #196.
13
- */
8
+ /** Sends "Configure SMTP" to the account's settings panel. */
14
9
  export const ComposeSmtpMissingBanner = ({
15
10
  accountId,
16
11
  }: ComposeSmtpMissingBannerProps) => {
17
12
  const navigate = useNavigate();
18
13
 
19
14
  return (
20
- <div
21
- role="alert"
22
- data-testid="compose-smtp-missing-banner"
23
- className="flex items-start gap-3 border-b border-warning/50 bg-warning/10 px-3 py-2"
24
- >
25
- <AlertTriangle
26
- className="size-5 shrink-0 mt-0.5 text-warning"
27
- aria-hidden="true"
28
- />
29
- <div className="flex-1 min-w-0">
30
- <p className="text-sm font-medium text-warning">
31
- This account can't send mail until SMTP is configured.
32
- </p>
33
- <button
34
- type="button"
35
- onClick={() => {
36
- navigate({
37
- to: "/settings/accounts",
38
- search: { editAccountId: accountId, focusSmtp: true },
39
- });
40
- }}
41
- className="mt-1 inline-flex items-center gap-1 text-xs font-medium text-warning hover:underline"
42
- >
43
- Configure SMTP
44
- <ArrowRight className="size-3" aria-hidden="true" />
45
- </button>
46
- </div>
47
- </div>
15
+ <Banner
16
+ onConfigure={() => {
17
+ navigate({
18
+ to: "/settings/accounts",
19
+ search: { editAccountId: accountId, focusSmtp: true },
20
+ });
21
+ }}
22
+ />
48
23
  );
49
24
  };
@@ -1,8 +1,8 @@
1
1
  import { configOperationsGetConfigOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
2
+ import { ConfirmDialog } from "@remit/ui";
2
3
  import { useQuery } from "@tanstack/react-query";
3
4
  import { useCallback, useRef, useState } from "react";
4
5
  import { Drawer } from "vaul";
5
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
6
6
  import { ErrorState } from "@/components/ui/ErrorState";
7
7
  import { ComposeForm } from "./ComposeForm";
8
8
  import { useCompose } from "./ComposeProvider";
@@ -1,6 +1,7 @@
1
1
  import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
2
2
  import {
3
3
  Banner,
4
+ ConfirmDialog,
4
5
  type Density,
5
6
  deriveIsMultiSelectMode,
6
7
  type MessageListFilter,
@@ -17,7 +18,6 @@ import { useVirtualizer } from "@tanstack/react-virtual";
17
18
  import { Search } from "lucide-react";
18
19
  import type { RefObject } from "react";
19
20
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
20
- import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
21
21
  import { useErrorBanners } from "@/components/ui/ErrorBannerProvider";
22
22
  import { formatErrorMessage } from "@/components/ui/ErrorState";
23
23
  import { useJunkMailbox } from "@/hooks/useArchiveMailbox";