@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 +2 -2
- package/src/components/compose/AddressField.tsx +20 -188
- package/src/components/compose/ComposeForm.tsx +101 -91
- 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/AccountFormPanel.tsx +23 -0
- package/src/components/settings/label-delete-confirm.stories.tsx +1 -1
- package/src/hooks/useComposeLanguages.ts +69 -0
- package/src/routes/settings/labels.tsx +1 -1
- package/src/components/compose/ComposeBody.stories.tsx +0 -274
- package/src/components/compose/ComposeBody.tsx +0 -170
- 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,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("
|
|
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:
|
|
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
|
-
//
|
|
200
|
+
// WiredComposeHeader — the shared header, with the app's fields in its slots
|
|
189
201
|
// ---------------------------------------------------------------------------
|
|
190
202
|
|
|
191
|
-
interface
|
|
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
|
|
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
|
-
}:
|
|
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
|
-
<
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
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="
|
|
304
|
-
addresses={
|
|
305
|
-
onChange={
|
|
256
|
+
label="To"
|
|
257
|
+
addresses={toAddresses}
|
|
258
|
+
onChange={setToAddresses}
|
|
259
|
+
placeholder="Recipients"
|
|
306
260
|
/>
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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";
|