@remit/web-client 0.0.138 → 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 +2 -2
- package/src/components/compose/AddressField.tsx +20 -188
- package/src/components/compose/ComposeForm.tsx +51 -87
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +10 -35
- package/src/components/compose/MobileComposeSheet.tsx +1 -1
- package/src/components/mail/MessageList.tsx +1 -1
- package/src/components/mail/ThreadListInteraction.tsx +6 -2
- package/src/components/settings/label-delete-confirm.stories.tsx +1 -1
- package/src/routes/settings/labels.tsx +1 -1
- package/src/components/compose/ComposeBody.stories.tsx +0 -463
- package/src/components/compose/ComposeBody.tsx +0 -210
- package/src/components/compose/SubjectField.tsx +0 -19
- package/src/components/compose/compose-mode.test.ts +0 -76
- package/src/components/compose/compose-mode.ts +0 -50
- package/src/components/ui/ConfirmDialog.stories.tsx +0 -54
- package/src/components/ui/ConfirmDialog.tsx +0 -140
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
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
|
|
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 {
|
|
4
|
+
import { useMemo, useState } from "react";
|
|
11
5
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
12
6
|
|
|
13
|
-
export
|
|
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
|
-
/**
|
|
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 [
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
const debouncedQuery = useDebouncedValue(inputValue, 200);
|
|
23
|
+
const [query, setQuery] = useState("");
|
|
24
|
+
const debouncedQuery = useDebouncedValue(query, 200);
|
|
56
25
|
|
|
57
|
-
const { data
|
|
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 =
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
...(suggestion.displayName ? { hint: suggestion.normalizedEmail } : {}),
|
|
35
|
+
(data?.items ?? []).map((item) => ({
|
|
36
|
+
email: item.normalizedEmail,
|
|
37
|
+
displayName: item.displayName,
|
|
121
38
|
})),
|
|
122
|
-
[
|
|
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
|
-
<
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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,8 +11,12 @@ import type {
|
|
|
11
11
|
import {
|
|
12
12
|
ComposeActionBar,
|
|
13
13
|
ComposeFormShell,
|
|
14
|
+
ComposeHeader,
|
|
15
|
+
ComposeSubjectField,
|
|
16
|
+
composeHeaderSummary,
|
|
14
17
|
defaultComposeLanguages,
|
|
15
18
|
EMPTY_RICH_TEXT,
|
|
19
|
+
modeOfDraft,
|
|
16
20
|
QuotedText,
|
|
17
21
|
type RichTextValue,
|
|
18
22
|
sanitizeQuotedHtml,
|
|
@@ -42,10 +46,9 @@ import {
|
|
|
42
46
|
import type { AddressEntry } from "./AddressField";
|
|
43
47
|
import { AddressField } from "./AddressField";
|
|
44
48
|
import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
|
|
45
|
-
import { modeOfDraft } from "./compose-mode";
|
|
46
49
|
|
|
47
50
|
const LazyComposeBody = lazy(() =>
|
|
48
|
-
import("
|
|
51
|
+
import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
|
|
49
52
|
);
|
|
50
53
|
|
|
51
54
|
const ComposeBodyFallback = () => (
|
|
@@ -60,7 +63,6 @@ import { useVisualViewport } from "../../hooks/useVisualViewport.js";
|
|
|
60
63
|
import type { ComposeMode } from "./ComposeProvider";
|
|
61
64
|
import { useCompose } from "./ComposeProvider";
|
|
62
65
|
import { FromSelector } from "./FromSelector";
|
|
63
|
-
import { SubjectField } from "./SubjectField";
|
|
64
66
|
|
|
65
67
|
interface ComposeFormProps {
|
|
66
68
|
mode: ComposeMode;
|
|
@@ -195,10 +197,10 @@ const isFormEmpty = (
|
|
|
195
197
|
body.text.trim() === "";
|
|
196
198
|
|
|
197
199
|
// ---------------------------------------------------------------------------
|
|
198
|
-
//
|
|
200
|
+
// WiredComposeHeader — the shared header, with the app's fields in its slots
|
|
199
201
|
// ---------------------------------------------------------------------------
|
|
200
202
|
|
|
201
|
-
interface
|
|
203
|
+
interface WiredComposeHeaderProps {
|
|
202
204
|
selectedAccountId?: string;
|
|
203
205
|
onAccountChange: (account: RemitImapAccountResponse) => void;
|
|
204
206
|
toAddresses: AddressEntry[];
|
|
@@ -215,7 +217,7 @@ interface ComposeHeaderProps {
|
|
|
215
217
|
setSubject: (v: string) => void;
|
|
216
218
|
}
|
|
217
219
|
|
|
218
|
-
const
|
|
220
|
+
const WiredComposeHeader = ({
|
|
219
221
|
selectedAccountId,
|
|
220
222
|
onAccountChange,
|
|
221
223
|
toAddresses,
|
|
@@ -230,93 +232,55 @@ const ComposeHeader = ({
|
|
|
230
232
|
setShowBcc,
|
|
231
233
|
subject,
|
|
232
234
|
setSubject,
|
|
233
|
-
}:
|
|
235
|
+
}: WiredComposeHeaderProps) => {
|
|
234
236
|
const isDesktop = useIsDesktop();
|
|
235
237
|
const { isKeyboardOpen } = useVisualViewport();
|
|
236
|
-
const collapsed = !isDesktop && isKeyboardOpen;
|
|
237
|
-
|
|
238
|
-
if (collapsed) {
|
|
239
|
-
// Compact single-line summary when the keyboard eats vertical space
|
|
240
|
-
const chips: string[] = [];
|
|
241
|
-
if (toAddresses.length > 0)
|
|
242
|
-
chips.push(
|
|
243
|
-
`To: ${toAddresses.map((a) => a.displayName ?? a.email).join(", ")}`,
|
|
244
|
-
);
|
|
245
|
-
if (ccAddresses.length > 0) chips.push(`Cc: ${ccAddresses.length}`);
|
|
246
|
-
if (bccAddresses.length > 0) chips.push(`Bcc: ${bccAddresses.length}`);
|
|
247
|
-
if (subject) chips.push(subject);
|
|
248
|
-
|
|
249
|
-
return (
|
|
250
|
-
<div
|
|
251
|
-
className="flex items-center gap-2 px-3 py-1.5 border-b border-line overflow-hidden"
|
|
252
|
-
data-testid="compose-header-collapsed"
|
|
253
|
-
>
|
|
254
|
-
<span className="truncate text-xs text-fg-muted">
|
|
255
|
-
{chips.length > 0 ? chips.join(" · ") : "…"}
|
|
256
|
-
</span>
|
|
257
|
-
<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">
|
|
258
|
-
…
|
|
259
|
-
</span>
|
|
260
|
-
</div>
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
238
|
|
|
264
239
|
return (
|
|
265
|
-
<
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
<AddressField
|
|
278
|
-
label="Cc"
|
|
279
|
-
addresses={ccAddresses}
|
|
280
|
-
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}
|
|
281
252
|
/>
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
<button
|
|
285
|
-
type="button"
|
|
286
|
-
onClick={() => setShowCc(true)}
|
|
287
|
-
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
|
288
|
-
>
|
|
289
|
-
Cc
|
|
290
|
-
</button>
|
|
291
|
-
<button
|
|
292
|
-
type="button"
|
|
293
|
-
onClick={() => setShowBcc(true)}
|
|
294
|
-
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
|
295
|
-
>
|
|
296
|
-
Bcc
|
|
297
|
-
</button>
|
|
298
|
-
</div>
|
|
299
|
-
)}
|
|
300
|
-
{showCc && !showBcc && (
|
|
301
|
-
<div className="pl-14">
|
|
302
|
-
<button
|
|
303
|
-
type="button"
|
|
304
|
-
onClick={() => setShowBcc(true)}
|
|
305
|
-
className="text-xs text-fg-muted hover:text-fg transition-colors"
|
|
306
|
-
>
|
|
307
|
-
Bcc
|
|
308
|
-
</button>
|
|
309
|
-
</div>
|
|
310
|
-
)}
|
|
311
|
-
{showBcc && (
|
|
253
|
+
}
|
|
254
|
+
to={
|
|
312
255
|
<AddressField
|
|
313
|
-
label="
|
|
314
|
-
addresses={
|
|
315
|
-
onChange={
|
|
256
|
+
label="To"
|
|
257
|
+
addresses={toAddresses}
|
|
258
|
+
onChange={setToAddresses}
|
|
259
|
+
placeholder="Recipients"
|
|
316
260
|
/>
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
+
/>
|
|
320
284
|
);
|
|
321
285
|
};
|
|
322
286
|
|
|
@@ -733,7 +697,7 @@ export const ComposeForm = ({
|
|
|
733
697
|
) : undefined
|
|
734
698
|
}
|
|
735
699
|
header={
|
|
736
|
-
<
|
|
700
|
+
<WiredComposeHeader
|
|
737
701
|
selectedAccountId={selectedAccountId}
|
|
738
702
|
onAccountChange={handleAccountChange}
|
|
739
703
|
toAddresses={toAddresses}
|
|
@@ -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
|
-
<
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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";
|
|
@@ -16,7 +16,12 @@
|
|
|
16
16
|
* are not rendered: focus stops moving, the highlight disappears, and the next
|
|
17
17
|
* verb acts on a message the user cannot see.
|
|
18
18
|
*/
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
ConfirmDialog,
|
|
21
|
+
SelectionTopBar,
|
|
22
|
+
useListCursor,
|
|
23
|
+
type Verb,
|
|
24
|
+
} from "@remit/ui";
|
|
20
25
|
import {
|
|
21
26
|
createContext,
|
|
22
27
|
type ReactNode,
|
|
@@ -28,7 +33,6 @@ import {
|
|
|
28
33
|
useRef,
|
|
29
34
|
useState,
|
|
30
35
|
} from "react";
|
|
31
|
-
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
32
36
|
import { useFollowFocusOpen } from "@/hooks/useFollowFocusOpen";
|
|
33
37
|
import { useIsDesktop } from "@/hooks/useMediaQuery";
|
|
34
38
|
import type { TriageContextUpdate } from "@/hooks/useTriageLayer";
|
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
import {
|
|
8
8
|
Banner,
|
|
9
9
|
Button,
|
|
10
|
+
ConfirmDialog,
|
|
10
11
|
Input,
|
|
11
12
|
labelColorOptions,
|
|
12
13
|
Select,
|
|
@@ -16,7 +17,6 @@ import { useQuery } from "@tanstack/react-query";
|
|
|
16
17
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
|
17
18
|
import { useState } from "react";
|
|
18
19
|
import { LabelsList } from "@/components/settings/LabelsList";
|
|
19
|
-
import { ConfirmDialog } from "@/components/ui/ConfirmDialog";
|
|
20
20
|
import { ErrorState } from "@/components/ui/ErrorState";
|
|
21
21
|
import {
|
|
22
22
|
useCreateLabel,
|