@remit/web-client 0.0.199 → 0.0.200
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.
|
|
3
|
+
"version": "0.0.200",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Remit web client, published as composable primitives — the app shell, auth shells, and runtime config. A distributor imports what it composes and bundles it.",
|
|
6
6
|
"exports": {
|
|
@@ -1,17 +1,24 @@
|
|
|
1
1
|
import { addressOperationsSearchAddressesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
type AddressEntry,
|
|
4
|
+
ComposeAddressField,
|
|
5
|
+
type ComposeAddressFieldHandle,
|
|
6
|
+
type ParsedAddressInput,
|
|
7
|
+
} from "@remit/ui";
|
|
3
8
|
import { useQuery } from "@tanstack/react-query";
|
|
4
|
-
import { useMemo, useState } from "react";
|
|
9
|
+
import { type Ref, useMemo, useState } from "react";
|
|
5
10
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
6
11
|
import { offerableAsRecipient } from "@/lib/recipient-suggestions";
|
|
7
12
|
|
|
8
|
-
export type { AddressEntry };
|
|
13
|
+
export type { AddressEntry, ComposeAddressFieldHandle, ParsedAddressInput };
|
|
9
14
|
|
|
10
15
|
interface AddressFieldProps {
|
|
11
16
|
label: string;
|
|
12
17
|
addresses: AddressEntry[];
|
|
13
18
|
onChange: (addresses: AddressEntry[]) => void;
|
|
14
19
|
placeholder?: string;
|
|
20
|
+
onPendingChange?: (pending: ParsedAddressInput) => void;
|
|
21
|
+
ref?: Ref<ComposeAddressFieldHandle>;
|
|
15
22
|
}
|
|
16
23
|
|
|
17
24
|
/** Looks the account's known correspondents up for the field to offer. */
|
|
@@ -20,6 +27,8 @@ export const AddressField = ({
|
|
|
20
27
|
addresses,
|
|
21
28
|
onChange,
|
|
22
29
|
placeholder,
|
|
30
|
+
onPendingChange,
|
|
31
|
+
ref,
|
|
23
32
|
}: AddressFieldProps) => {
|
|
24
33
|
const [query, setQuery] = useState("");
|
|
25
34
|
const debouncedQuery = useDebouncedValue(query, 200);
|
|
@@ -48,6 +57,8 @@ export const AddressField = ({
|
|
|
48
57
|
placeholder={placeholder}
|
|
49
58
|
suggestions={suggestions}
|
|
50
59
|
onQueryChange={setQuery}
|
|
60
|
+
onPendingChange={onPendingChange}
|
|
61
|
+
ref={ref}
|
|
51
62
|
/>
|
|
52
63
|
);
|
|
53
64
|
};
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
} from "@remit/ui";
|
|
29
29
|
import type { ComposeBodyMode } from "@remit/ui/rich-text";
|
|
30
30
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
31
|
+
import type { RefObject } from "react";
|
|
31
32
|
import {
|
|
32
33
|
lazy,
|
|
33
34
|
Suspense,
|
|
@@ -47,7 +48,11 @@ import {
|
|
|
47
48
|
buildMutationErrorBanner,
|
|
48
49
|
formatErrorDetail,
|
|
49
50
|
} from "../ui/error-banners.js";
|
|
50
|
-
import type {
|
|
51
|
+
import type {
|
|
52
|
+
AddressEntry,
|
|
53
|
+
ComposeAddressFieldHandle,
|
|
54
|
+
ParsedAddressInput,
|
|
55
|
+
} from "./AddressField";
|
|
51
56
|
import { AddressField } from "./AddressField";
|
|
52
57
|
import { ComposeSmtpMissingBanner } from "./ComposeSmtpMissingBanner";
|
|
53
58
|
import { composeSpellcheck } from "./compose-spellcheck.js";
|
|
@@ -213,6 +218,20 @@ type SendReadiness =
|
|
|
213
218
|
| { status: "blocked"; reason: string }
|
|
214
219
|
| { status: "ready"; accountId: string };
|
|
215
220
|
|
|
221
|
+
/** The three recipient lists a send goes out with. */
|
|
222
|
+
interface Recipients {
|
|
223
|
+
to: AddressEntry[];
|
|
224
|
+
cc: AddressEntry[];
|
|
225
|
+
bcc: AddressEntry[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** The fields a send takes its recipients from, in the order they are read. */
|
|
229
|
+
interface AddressFieldHandles {
|
|
230
|
+
to: RefObject<ComposeAddressFieldHandle | null>;
|
|
231
|
+
cc: RefObject<ComposeAddressFieldHandle | null>;
|
|
232
|
+
bcc: RefObject<ComposeAddressFieldHandle | null>;
|
|
233
|
+
}
|
|
234
|
+
|
|
216
235
|
/**
|
|
217
236
|
* Naming To, not "a recipient". Sending goes through the draft, and a draft is
|
|
218
237
|
* created against `CreateOutboxMessageInput`, whose `@minItems(1)` is on
|
|
@@ -224,6 +243,28 @@ type SendReadiness =
|
|
|
224
243
|
*/
|
|
225
244
|
const NO_TO_ADDRESS_MESSAGE = "Add a To address before sending.";
|
|
226
245
|
|
|
246
|
+
/** A field holding text that is not an address, and which field it is. */
|
|
247
|
+
interface UnparsedField {
|
|
248
|
+
label: string;
|
|
249
|
+
text: string;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* Text a recipient field could not read as an address stops the send and is
|
|
254
|
+
* quoted back. It is on screen and it was meant for somebody, so sending
|
|
255
|
+
* without it would deliver a message to fewer people than it was addressed to,
|
|
256
|
+
* and the composer closing on the send would take the text with it.
|
|
257
|
+
*/
|
|
258
|
+
const unparsedRefusal = ({ label, text }: UnparsedField): string =>
|
|
259
|
+
`${label} holds "${text}", which is not an address.`;
|
|
260
|
+
|
|
261
|
+
const firstUnparsed = (
|
|
262
|
+
fields: readonly UnparsedField[],
|
|
263
|
+
): UnparsedField | undefined =>
|
|
264
|
+
fields.find((field) => field.text.trim() !== "");
|
|
265
|
+
|
|
266
|
+
const EMPTY_PENDING: ParsedAddressInput = { entries: [], unparsed: "" };
|
|
267
|
+
|
|
227
268
|
const isFormEmpty = (
|
|
228
269
|
toAddresses: AddressEntry[],
|
|
229
270
|
ccAddresses: AddressEntry[],
|
|
@@ -257,6 +298,8 @@ interface WiredComposeHeaderProps {
|
|
|
257
298
|
setShowBcc: (v: boolean) => void;
|
|
258
299
|
subject: string;
|
|
259
300
|
setSubject: (v: string) => void;
|
|
301
|
+
fieldHandles: AddressFieldHandles;
|
|
302
|
+
onToPendingChange: (pending: ParsedAddressInput) => void;
|
|
260
303
|
}
|
|
261
304
|
|
|
262
305
|
const WiredComposeHeader = ({
|
|
@@ -275,6 +318,8 @@ const WiredComposeHeader = ({
|
|
|
275
318
|
setShowBcc,
|
|
276
319
|
subject,
|
|
277
320
|
setSubject,
|
|
321
|
+
fieldHandles,
|
|
322
|
+
onToPendingChange,
|
|
278
323
|
}: WiredComposeHeaderProps) => {
|
|
279
324
|
const isDesktop = useIsDesktop();
|
|
280
325
|
const { isKeyboardOpen } = useVisualViewport();
|
|
@@ -307,6 +352,8 @@ const WiredComposeHeader = ({
|
|
|
307
352
|
addresses={toAddresses}
|
|
308
353
|
onChange={setToAddresses}
|
|
309
354
|
placeholder="Recipients"
|
|
355
|
+
onPendingChange={onToPendingChange}
|
|
356
|
+
ref={fieldHandles.to}
|
|
310
357
|
/>
|
|
311
358
|
}
|
|
312
359
|
cc={
|
|
@@ -315,6 +362,7 @@ const WiredComposeHeader = ({
|
|
|
315
362
|
label="Cc"
|
|
316
363
|
addresses={ccAddresses}
|
|
317
364
|
onChange={setCcAddresses}
|
|
365
|
+
ref={fieldHandles.cc}
|
|
318
366
|
/>
|
|
319
367
|
) : undefined
|
|
320
368
|
}
|
|
@@ -324,6 +372,7 @@ const WiredComposeHeader = ({
|
|
|
324
372
|
label="Bcc"
|
|
325
373
|
addresses={bccAddresses}
|
|
326
374
|
onChange={setBccAddresses}
|
|
375
|
+
ref={fieldHandles.bcc}
|
|
327
376
|
/>
|
|
328
377
|
) : undefined
|
|
329
378
|
}
|
|
@@ -350,6 +399,25 @@ export const ComposeForm = ({
|
|
|
350
399
|
const [toAddresses, setToAddresses] = useState<AddressEntry[]>([]);
|
|
351
400
|
const [ccAddresses, setCcAddresses] = useState<AddressEntry[]>([]);
|
|
352
401
|
const [bccAddresses, setBccAddresses] = useState<AddressEntry[]>([]);
|
|
402
|
+
/**
|
|
403
|
+
* What To is holding but has not committed. A press on Send takes it, so the
|
|
404
|
+
* refusal for having no recipient must not stand while an address is on
|
|
405
|
+
* screen — the button's own reason comes from this state, before the press.
|
|
406
|
+
*/
|
|
407
|
+
const [pendingTo, setPendingTo] = useState<ParsedAddressInput>(EMPTY_PENDING);
|
|
408
|
+
const toFieldRef = useRef<ComposeAddressFieldHandle>(null);
|
|
409
|
+
const ccFieldRef = useRef<ComposeAddressFieldHandle>(null);
|
|
410
|
+
const bccFieldRef = useRef<ComposeAddressFieldHandle>(null);
|
|
411
|
+
const fieldHandles = useMemo<AddressFieldHandles>(
|
|
412
|
+
() => ({ to: toFieldRef, cc: ccFieldRef, bcc: bccFieldRef }),
|
|
413
|
+
[],
|
|
414
|
+
);
|
|
415
|
+
/** A new document leaves nothing typed behind in a field that did not remount. */
|
|
416
|
+
const clearPendingFields = useCallback(() => {
|
|
417
|
+
toFieldRef.current?.clearPending();
|
|
418
|
+
ccFieldRef.current?.clearPending();
|
|
419
|
+
bccFieldRef.current?.clearPending();
|
|
420
|
+
}, []);
|
|
353
421
|
const [subject, setSubject] = useState("");
|
|
354
422
|
const [showCc, setShowCc] = useState(false);
|
|
355
423
|
const [showBcc, setShowBcc] = useState(false);
|
|
@@ -420,6 +488,7 @@ export const ComposeForm = ({
|
|
|
420
488
|
setToAddresses([]);
|
|
421
489
|
setCcAddresses([]);
|
|
422
490
|
setBccAddresses([]);
|
|
491
|
+
clearPendingFields();
|
|
423
492
|
setSubject("");
|
|
424
493
|
setShowCc(false);
|
|
425
494
|
setShowBcc(false);
|
|
@@ -431,7 +500,7 @@ export const ComposeForm = ({
|
|
|
431
500
|
setDocumentGeneration((generation) => generation + 1);
|
|
432
501
|
setDraftLoaded(false);
|
|
433
502
|
setOpenDocumentId(outboxMessageId);
|
|
434
|
-
}, [outboxMessageId]);
|
|
503
|
+
}, [outboxMessageId, clearPendingFields]);
|
|
435
504
|
|
|
436
505
|
const { signature } = useSignature(selectedAccountId);
|
|
437
506
|
// Read when a new message starts, never depended on: a signature arriving is
|
|
@@ -527,6 +596,11 @@ export const ComposeForm = ({
|
|
|
527
596
|
if (resumedDraftRef.current) return;
|
|
528
597
|
if (!sourceMessage) return;
|
|
529
598
|
|
|
599
|
+
// The fields are being rewritten for a different answer, so what was typed
|
|
600
|
+
// into one and left there belongs to the answer being left behind. Without
|
|
601
|
+
// this the blur timer commits it into the new one 150 ms later.
|
|
602
|
+
clearPendingFields();
|
|
603
|
+
|
|
530
604
|
if (mode === "reply" || mode === "reply-all") {
|
|
531
605
|
const { to, cc } = getReplyAddresses(sourceMessage, mode, account?.email);
|
|
532
606
|
setToAddresses(to);
|
|
@@ -545,7 +619,7 @@ export const ComposeForm = ({
|
|
|
545
619
|
setBccAddresses([]);
|
|
546
620
|
setSubject(buildForwardSubject(sourceMessage.envelope.subject));
|
|
547
621
|
}
|
|
548
|
-
}, [mode, sourceMessage, account?.email]);
|
|
622
|
+
}, [mode, sourceMessage, account?.email, clearPendingFields]);
|
|
549
623
|
|
|
550
624
|
// Quoted reply/forward content lives at the per-part `contentUrl` since
|
|
551
625
|
// #224 PR 3 — fetch it via the same hook MessageBody uses, and degrade
|
|
@@ -666,28 +740,42 @@ export const ComposeForm = ({
|
|
|
666
740
|
// no-op: the state it reads has no way to be blocked without a reason. A
|
|
667
741
|
// ready state carries the account the message goes out from, so the send
|
|
668
742
|
// path has no condition of its own left to refuse on in silence.
|
|
669
|
-
const
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
743
|
+
const readinessFor = useCallback(
|
|
744
|
+
(toCount: number, unparsed: UnparsedField | undefined): SendReadiness => {
|
|
745
|
+
if (isSending) return { status: "sending" };
|
|
746
|
+
if (!selectedAccountId) {
|
|
747
|
+
return { status: "blocked", reason: "Choose an account to send from." };
|
|
748
|
+
}
|
|
749
|
+
if (selectedAccountMissingSmtp) {
|
|
750
|
+
return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
|
|
751
|
+
}
|
|
752
|
+
if (unparsed) {
|
|
753
|
+
return { status: "blocked", reason: unparsedRefusal(unparsed) };
|
|
754
|
+
}
|
|
755
|
+
if (toCount === 0) {
|
|
756
|
+
return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
|
|
757
|
+
}
|
|
758
|
+
return { status: "ready", accountId: selectedAccountId };
|
|
759
|
+
},
|
|
760
|
+
[isSending, selectedAccountId, selectedAccountMissingSmtp],
|
|
761
|
+
);
|
|
762
|
+
const sendReadiness = useMemo<SendReadiness>(
|
|
763
|
+
() =>
|
|
764
|
+
readinessFor(
|
|
765
|
+
toAddresses.length + pendingTo.entries.length,
|
|
766
|
+
firstUnparsed([{ label: "To", text: pendingTo.unparsed }]),
|
|
767
|
+
),
|
|
768
|
+
[readinessFor, toAddresses.length, pendingTo],
|
|
769
|
+
);
|
|
687
770
|
const sendState: ComposeSendState = sendReadiness;
|
|
688
771
|
|
|
689
772
|
useEffect(() => {
|
|
690
773
|
if (!selectedAccountId) return;
|
|
774
|
+
// Nor one that is on its way out. Send commits the address fields as it
|
|
775
|
+
// goes, which lands here as a recipient change and would re-arm the timer
|
|
776
|
+
// `stopAutoSave` has just dropped — a draft write arriving after the send
|
|
777
|
+
// took the row out of draft (#845.6).
|
|
778
|
+
if (sendInFlightRef.current) return;
|
|
691
779
|
// Don't autosave a document the fields are no longer on. They trail the
|
|
692
780
|
// address by a commit whenever it changes, so writing here sends the
|
|
693
781
|
// message just left to whatever the address now names — the previous
|
|
@@ -734,7 +822,7 @@ export const ComposeForm = ({
|
|
|
734
822
|
]);
|
|
735
823
|
|
|
736
824
|
const handleSend = useCallback(
|
|
737
|
-
async (accountId: string) => {
|
|
825
|
+
async (accountId: string, recipients: Recipients) => {
|
|
738
826
|
if (sendInFlightRef.current) return;
|
|
739
827
|
|
|
740
828
|
sendInFlightRef.current = true;
|
|
@@ -760,14 +848,14 @@ export const ComposeForm = ({
|
|
|
760
848
|
// that fails stops the send rather than transmitting the older copy.
|
|
761
849
|
const flushed = await saveImmediately({
|
|
762
850
|
accountId,
|
|
763
|
-
toAddresses:
|
|
851
|
+
toAddresses: recipients.to.map((a) => a.email),
|
|
764
852
|
ccAddresses:
|
|
765
|
-
|
|
766
|
-
?
|
|
853
|
+
recipients.cc.length > 0
|
|
854
|
+
? recipients.cc.map((a) => a.email)
|
|
767
855
|
: undefined,
|
|
768
856
|
bccAddresses:
|
|
769
|
-
|
|
770
|
-
?
|
|
857
|
+
recipients.bcc.length > 0
|
|
858
|
+
? recipients.bcc.map((a) => a.email)
|
|
771
859
|
: undefined,
|
|
772
860
|
subject: subject || undefined,
|
|
773
861
|
textBody,
|
|
@@ -815,9 +903,6 @@ export const ComposeForm = ({
|
|
|
815
903
|
}
|
|
816
904
|
},
|
|
817
905
|
[
|
|
818
|
-
toAddresses,
|
|
819
|
-
ccAddresses,
|
|
820
|
-
bccAddresses,
|
|
821
906
|
subject,
|
|
822
907
|
body,
|
|
823
908
|
bodyMode,
|
|
@@ -856,14 +941,49 @@ export const ComposeForm = ({
|
|
|
856
941
|
[pushError],
|
|
857
942
|
);
|
|
858
943
|
|
|
944
|
+
/**
|
|
945
|
+
* Send goes out to the addresses on screen, not to the ones the fields have
|
|
946
|
+
* got round to committing. Each field commits on blur behind a timer, and the
|
|
947
|
+
* press that sends is the press that blurs — so a recipient typed and left in
|
|
948
|
+
* the field is taken here, in the same tick, before the message is judged to
|
|
949
|
+
* have anywhere to go (#845.6).
|
|
950
|
+
*/
|
|
859
951
|
const attemptSend = useCallback(() => {
|
|
860
952
|
if (sendReadiness.status === "sending") return;
|
|
861
|
-
|
|
862
|
-
|
|
953
|
+
|
|
954
|
+
const to = fieldHandles.to.current?.commitPending();
|
|
955
|
+
const cc = fieldHandles.cc.current?.commitPending();
|
|
956
|
+
const bcc = fieldHandles.bcc.current?.commitPending();
|
|
957
|
+
const recipients: Recipients = {
|
|
958
|
+
to: to?.addresses ?? toAddresses,
|
|
959
|
+
cc: cc?.addresses ?? ccAddresses,
|
|
960
|
+
bcc: bcc?.addresses ?? bccAddresses,
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
const readiness = readinessFor(
|
|
964
|
+
recipients.to.length,
|
|
965
|
+
firstUnparsed([
|
|
966
|
+
{ label: "To", text: to?.unparsed ?? "" },
|
|
967
|
+
{ label: "Cc", text: cc?.unparsed ?? "" },
|
|
968
|
+
{ label: "Bcc", text: bcc?.unparsed ?? "" },
|
|
969
|
+
]),
|
|
970
|
+
);
|
|
971
|
+
if (readiness.status === "sending") return;
|
|
972
|
+
if (readiness.status === "blocked") {
|
|
973
|
+
reportBlocked(readiness.reason);
|
|
863
974
|
return;
|
|
864
975
|
}
|
|
865
|
-
void handleSend(
|
|
866
|
-
}, [
|
|
976
|
+
void handleSend(readiness.accountId, recipients);
|
|
977
|
+
}, [
|
|
978
|
+
sendReadiness.status,
|
|
979
|
+
fieldHandles,
|
|
980
|
+
toAddresses,
|
|
981
|
+
ccAddresses,
|
|
982
|
+
bccAddresses,
|
|
983
|
+
readinessFor,
|
|
984
|
+
reportBlocked,
|
|
985
|
+
handleSend,
|
|
986
|
+
]);
|
|
867
987
|
|
|
868
988
|
const handleAccountChange = useCallback(
|
|
869
989
|
(acct: RemitImapAccountResponse) => {
|
|
@@ -901,6 +1021,8 @@ export const ComposeForm = ({
|
|
|
901
1021
|
setShowBcc={setShowBcc}
|
|
902
1022
|
subject={subject}
|
|
903
1023
|
setSubject={setSubject}
|
|
1024
|
+
fieldHandles={fieldHandles}
|
|
1025
|
+
onToPendingChange={setPendingTo}
|
|
904
1026
|
/>
|
|
905
1027
|
}
|
|
906
1028
|
quoted={
|