@remit/web-client 0.0.199 → 0.0.201

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.199",
3
+ "version": "0.0.201",
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 { type AddressEntry, ComposeAddressField } from "@remit/ui";
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 { AddressEntry } from "./AddressField";
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 sendReadiness = useMemo<SendReadiness>(() => {
670
- if (isSending) return { status: "sending" };
671
- if (!selectedAccountId) {
672
- return { status: "blocked", reason: "Choose an account to send from." };
673
- }
674
- if (selectedAccountMissingSmtp) {
675
- return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
676
- }
677
- if (toAddresses.length === 0) {
678
- return { status: "blocked", reason: NO_TO_ADDRESS_MESSAGE };
679
- }
680
- return { status: "ready", accountId: selectedAccountId };
681
- }, [
682
- isSending,
683
- selectedAccountId,
684
- selectedAccountMissingSmtp,
685
- toAddresses.length,
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: toAddresses.map((a) => a.email),
851
+ toAddresses: recipients.to.map((a) => a.email),
764
852
  ccAddresses:
765
- ccAddresses.length > 0
766
- ? ccAddresses.map((a) => a.email)
853
+ recipients.cc.length > 0
854
+ ? recipients.cc.map((a) => a.email)
767
855
  : undefined,
768
856
  bccAddresses:
769
- bccAddresses.length > 0
770
- ? bccAddresses.map((a) => a.email)
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
- if (sendReadiness.status === "blocked") {
862
- reportBlocked(sendReadiness.reason);
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(sendReadiness.accountId);
866
- }, [sendReadiness, reportBlocked, handleSend]);
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={
@@ -1,41 +1,82 @@
1
- import assert from "node:assert/strict";
2
- import { readFileSync } from "node:fs";
3
- import { dirname, resolve } from "node:path";
4
- import { describe, it } from "node:test";
5
- import { fileURLToPath } from "node:url";
6
-
7
1
  /**
8
2
  * A filter made from a search belongs to the account the search ran over (#524).
9
- * The surface hands its account to the host on the props; a search entry that
10
- * reads past it writes the rule against whichever account happens to be first in
11
- * the list, and the mail the user was looking at is never filtered.
12
3
  *
13
- * Which account wins is decided — and tested — in
14
- * `../../lib/organize/search-to-rule.test.ts`. What is read off the source here
15
- * is that the host asks that question with the surface's account in hand, which
16
- * is the wiring the bug lived in and which no unit of the resolver can prove.
17
- * The host wires routing, history and several data hooks together, so it is read
18
- * rather than rendered, as this package's other component-level rules are (see
19
- * `SelectionWizardHost.run-exit.test.ts`).
4
+ * The surface hands its account to the host on the props; a search entry that
5
+ * reaches past it writes the rule against whichever account happens to be first
6
+ * in the list, and the mail the user was looking at is never filtered. Which
7
+ * account wins between the query and the surface is
8
+ * `../../lib/organize/search-to-rule.test.ts`; what is asserted here is the set
9
+ * of props a search entry replaces, the account among them.
20
10
  */
21
11
 
22
- const here = dirname(fileURLToPath(import.meta.url));
23
- const source = readFileSync(resolve(here, "SelectionWizardHost.tsx"), "utf8");
12
+ import assert from "node:assert/strict";
13
+ import { describe, it } from "node:test";
14
+ import type { SearchConversion } from "@remit/ui";
15
+ import { searchEntryOverrides } from "./SelectionWizardHost";
16
+
17
+ const conversion = (
18
+ over: Partial<SearchConversion> = {},
19
+ ): SearchConversion => ({
20
+ clauses: [],
21
+ matchOperator: "all",
22
+ droppedFacets: [],
23
+ keptTerms: false,
24
+ droppedSemantic: false,
25
+ ...over,
26
+ });
27
+
28
+ const accounts = [{ accountId: "acc-first" }, { accountId: "acc-surface" }];
29
+
30
+ describe("the account a search entry writes its rule against", () => {
31
+ it("is the one the surface handed over", () => {
32
+ assert.equal(
33
+ searchEntryOverrides(conversion(), { accountId: "acc-surface" }, accounts)
34
+ .accountId,
35
+ "acc-surface",
36
+ );
37
+ });
24
38
 
25
- const host = source.match(
26
- /export function SelectionWizardHost\(([\s\S]*)$/,
27
- )?.[1];
39
+ it("is the one the query names when it names one", () => {
40
+ assert.equal(
41
+ searchEntryOverrides(
42
+ conversion({ targetAccountId: "acc-typed" }),
43
+ { accountId: "acc-surface" },
44
+ accounts,
45
+ ).accountId,
46
+ "acc-typed",
47
+ );
48
+ });
49
+
50
+ // The first configured account is the last resort, never the answer while the
51
+ // surface has one of its own.
52
+ it("falls back to the first configured account only when the surface has none", () => {
53
+ assert.equal(
54
+ searchEntryOverrides(conversion(), { accountId: undefined }, accounts)
55
+ .accountId,
56
+ "acc-first",
57
+ );
58
+ });
59
+ });
28
60
 
29
- describe("the search entry's account", () => {
30
- it("is resolved with the account the surface handed over", () => {
31
- assert.ok(host, "the host is gone");
32
- const call = host.match(/searchRuleAccountId\(([\s\S]*?)\)/)?.[1];
33
- assert.ok(call, "the search entry resolves no account of its own");
34
- assert.match(call, /\bprops\.accountId\b/);
61
+ describe("what else a search entry replaces", () => {
62
+ it("ticks nothing, carries no restriction and drops any escalation", () => {
63
+ const overrides = searchEntryOverrides(
64
+ conversion(),
65
+ { accountId: "acc-surface" },
66
+ accounts,
67
+ );
68
+ assert.equal(overrides.verb, "organize");
69
+ assert.deepEqual(overrides.selection, []);
70
+ assert.equal(overrides.selectionRestriction, undefined);
71
+ assert.equal(overrides.escalated, undefined);
35
72
  });
36
73
 
37
- it("never reaches past it for the first configured account", () => {
38
- assert.ok(host, "the host is gone");
39
- assert.doesNotMatch(host, /accounts\[0\]/);
74
+ it("hands the conversion on so the properties step can seed its clauses", () => {
75
+ const seeded = conversion({ keptTerms: true });
76
+ assert.equal(
77
+ searchEntryOverrides(seeded, { accountId: "acc-surface" }, accounts)
78
+ .searchConversion,
79
+ seeded,
80
+ );
40
81
  });
41
82
  });