@remit/web-client 0.0.144 → 0.0.146
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 +1 -1
- package/src/components/compose/ComposeForm.tsx +169 -120
- package/src/components/compose/ComposeSmtpMissingBanner.tsx +4 -0
- package/src/components/compose/mobile-header-stays-expanded.render.test.ts +182 -0
- package/src/components/layout/MailShell.tsx +98 -0
- package/src/components/mail/BriefPane.tsx +5 -5
- package/src/components/mail/FlaggedPane.tsx +1 -1
- package/src/components/mail/MailSidebarAdapter.tsx +5 -8
- package/src/components/mail/MailboxPane.tsx +5 -5
- package/src/components/ui/FatalErrorOverlay.tsx +4 -1
- package/src/hooks/useSearchMirror.ts +93 -0
- package/src/hooks/useSearchScope.ts +1 -1
- package/src/lib/compose-routes.test.ts +3 -1
- package/src/lib/compose-routes.ts +1 -1
- package/src/lib/mail-route.test.ts +157 -57
- package/src/lib/mail-route.ts +71 -38
- package/src/lib/mail-search.ts +51 -0
- package/src/lib/route-search-query.test.ts +50 -28
- package/src/lib/search-scope.ts +11 -19
- package/src/lib/search-view.test.ts +142 -38
- package/src/lib/search-view.ts +35 -11
- package/src/routeTree.gen.ts +155 -18
- package/src/router.tsx +17 -0
- package/src/routes/index.tsx +1 -1
- package/src/routes/mail/$mailboxId/index.tsx +10 -0
- package/src/routes/mail/$mailboxId.tsx +27 -19
- package/src/routes/mail/brief/index.tsx +14 -0
- package/src/routes/mail/brief.tsx +50 -0
- package/src/routes/mail/flagged/index.tsx +10 -0
- package/src/routes/mail/flagged.tsx +25 -13
- package/src/routes/mail/index.tsx +9 -36
- package/src/routes/mail/outbox/index.tsx +10 -0
- package/src/routes/mail/outbox.tsx +23 -13
- package/src/routes/mail.tsx +35 -246
- package/src/test-support/list-search-binding.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.146",
|
|
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": {
|
|
@@ -10,8 +10,10 @@ import type {
|
|
|
10
10
|
} from "@remit/api-http-client/types.gen.ts";
|
|
11
11
|
import {
|
|
12
12
|
ComposeActionBar,
|
|
13
|
+
ComposeBodySkeleton,
|
|
13
14
|
ComposeFormShell,
|
|
14
15
|
ComposeHeader,
|
|
16
|
+
type ComposeSendState,
|
|
15
17
|
ComposeSubjectField,
|
|
16
18
|
composeHeaderSummary,
|
|
17
19
|
defaultComposeLanguages,
|
|
@@ -19,6 +21,7 @@ import {
|
|
|
19
21
|
modeOfDraft,
|
|
20
22
|
QuotedText,
|
|
21
23
|
type RichTextValue,
|
|
24
|
+
SMTP_MISSING_MESSAGE,
|
|
22
25
|
sanitizeQuotedHtml,
|
|
23
26
|
unwrapLanguage,
|
|
24
27
|
wrapWithLanguage,
|
|
@@ -51,13 +54,6 @@ const LazyComposeBody = lazy(() =>
|
|
|
51
54
|
import("@remit/ui/rich-text").then((m) => ({ default: m.ComposeBody })),
|
|
52
55
|
);
|
|
53
56
|
|
|
54
|
-
const ComposeBodyFallback = () => (
|
|
55
|
-
<div className="min-h-[120px] px-3 py-2">
|
|
56
|
-
<div className="h-8 mb-2 rounded bg-surface-sunken animate-pulse" />
|
|
57
|
-
<div className="min-h-[80px] rounded bg-surface-sunken/50 animate-pulse" />
|
|
58
|
-
</div>
|
|
59
|
-
);
|
|
60
|
-
|
|
61
57
|
import { useIsDesktop } from "../../hooks/useMediaQuery.js";
|
|
62
58
|
import { useVisualViewport } from "../../hooks/useVisualViewport.js";
|
|
63
59
|
import type { ComposeMode } from "./ComposeProvider";
|
|
@@ -183,6 +179,11 @@ const outgoingBody = (
|
|
|
183
179
|
: undefined,
|
|
184
180
|
});
|
|
185
181
|
|
|
182
|
+
type SendReadiness =
|
|
183
|
+
| { status: "sending" }
|
|
184
|
+
| { status: "blocked"; reason: string }
|
|
185
|
+
| { status: "ready"; accountId: string };
|
|
186
|
+
|
|
186
187
|
const isFormEmpty = (
|
|
187
188
|
toAddresses: AddressEntry[],
|
|
188
189
|
ccAddresses: AddressEntry[],
|
|
@@ -201,6 +202,7 @@ const isFormEmpty = (
|
|
|
201
202
|
// ---------------------------------------------------------------------------
|
|
202
203
|
|
|
203
204
|
interface WiredComposeHeaderProps {
|
|
205
|
+
documentGeneration: number;
|
|
204
206
|
selectedAccountId?: string;
|
|
205
207
|
onAccountChange: (account: RemitImapAccountResponse) => void;
|
|
206
208
|
toAddresses: AddressEntry[];
|
|
@@ -218,6 +220,7 @@ interface WiredComposeHeaderProps {
|
|
|
218
220
|
}
|
|
219
221
|
|
|
220
222
|
const WiredComposeHeader = ({
|
|
223
|
+
documentGeneration,
|
|
221
224
|
selectedAccountId,
|
|
222
225
|
onAccountChange,
|
|
223
226
|
toAddresses,
|
|
@@ -235,10 +238,17 @@ const WiredComposeHeader = ({
|
|
|
235
238
|
}: WiredComposeHeaderProps) => {
|
|
236
239
|
const isDesktop = useIsDesktop();
|
|
237
240
|
const { isKeyboardOpen } = useVisualViewport();
|
|
241
|
+
// What the user asked to see belongs to the document being written, not to
|
|
242
|
+
// the keyboard. Tying it to the keyboard collapsed the rows again the moment
|
|
243
|
+
// one came back up — over the recipient field being typed into, which took
|
|
244
|
+
// the keyboard down with it and started the cycle over.
|
|
245
|
+
const [expandedFor, setExpandedFor] = useState<number | undefined>(undefined);
|
|
246
|
+
const expanded = expandedFor === documentGeneration;
|
|
238
247
|
|
|
239
248
|
return (
|
|
240
249
|
<ComposeHeader
|
|
241
|
-
collapsed={!isDesktop && isKeyboardOpen}
|
|
250
|
+
collapsed={!isDesktop && isKeyboardOpen && !expanded}
|
|
251
|
+
onExpand={() => setExpandedFor(documentGeneration)}
|
|
242
252
|
summary={composeHeaderSummary({
|
|
243
253
|
to: toAddresses,
|
|
244
254
|
cc: ccAddresses,
|
|
@@ -305,6 +315,7 @@ export const ComposeForm = ({
|
|
|
305
315
|
account?.accountId,
|
|
306
316
|
);
|
|
307
317
|
const [draftLoaded, setDraftLoaded] = useState(false);
|
|
318
|
+
const smtpConfigureRef = useRef<HTMLButtonElement>(null);
|
|
308
319
|
const prevOutboxMessageIdRef = useRef<string | undefined>(outboxMessageId);
|
|
309
320
|
|
|
310
321
|
// Reset form when the user switches to a different draft (outboxMessageId
|
|
@@ -523,14 +534,33 @@ export const ComposeForm = ({
|
|
|
523
534
|
);
|
|
524
535
|
|
|
525
536
|
// The action bar refuses a second press while one is in flight, but the
|
|
526
|
-
// editor's own Cmd+Enter goes straight to `
|
|
537
|
+
// editor's own Cmd+Enter goes straight to `attemptSend`, and the write that
|
|
527
538
|
// now precedes the request widens the window a second press lands in.
|
|
528
539
|
const sendInFlightRef = useRef(false);
|
|
529
540
|
const [isSending, setIsSending] = useState(false);
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
541
|
+
// Every refusal carries the sentence that explains it. Send is never a
|
|
542
|
+
// no-op: the state it reads has no way to be blocked without a reason. A
|
|
543
|
+
// ready state carries the account the message goes out from, so the send
|
|
544
|
+
// path has no condition of its own left to refuse on in silence.
|
|
545
|
+
const sendReadiness = useMemo<SendReadiness>(() => {
|
|
546
|
+
if (isSending) return { status: "sending" };
|
|
547
|
+
if (!selectedAccountId) {
|
|
548
|
+
return { status: "blocked", reason: "Choose an account to send from." };
|
|
549
|
+
}
|
|
550
|
+
if (selectedAccountMissingSmtp) {
|
|
551
|
+
return { status: "blocked", reason: SMTP_MISSING_MESSAGE };
|
|
552
|
+
}
|
|
553
|
+
if (toAddresses.length === 0) {
|
|
554
|
+
return { status: "blocked", reason: "Add at least one recipient." };
|
|
555
|
+
}
|
|
556
|
+
return { status: "ready", accountId: selectedAccountId };
|
|
557
|
+
}, [
|
|
558
|
+
isSending,
|
|
559
|
+
selectedAccountId,
|
|
560
|
+
selectedAccountMissingSmtp,
|
|
561
|
+
toAddresses.length,
|
|
562
|
+
]);
|
|
563
|
+
const sendState: ComposeSendState = sendReadiness;
|
|
534
564
|
|
|
535
565
|
useEffect(() => {
|
|
536
566
|
if (!selectedAccountId) return;
|
|
@@ -573,103 +603,106 @@ export const ComposeForm = ({
|
|
|
573
603
|
saveDraft,
|
|
574
604
|
]);
|
|
575
605
|
|
|
576
|
-
const handleSend = useCallback(
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
if (flushed.outcome === "failed") {
|
|
617
|
-
pushError({
|
|
618
|
-
title: "Couldn't send message",
|
|
619
|
-
detail:
|
|
620
|
-
formatErrorDetail(flushed.error) ??
|
|
621
|
-
"Saving the message failed, so nothing was sent. Try again.",
|
|
622
|
-
error: flushed.error,
|
|
606
|
+
const handleSend = useCallback(
|
|
607
|
+
async (accountId: string) => {
|
|
608
|
+
if (sendInFlightRef.current) return;
|
|
609
|
+
|
|
610
|
+
sendInFlightRef.current = true;
|
|
611
|
+
setIsSending(true);
|
|
612
|
+
try {
|
|
613
|
+
stopAutoSave();
|
|
614
|
+
|
|
615
|
+
const replyData =
|
|
616
|
+
sourceMessage && (mode === "reply" || mode === "reply_all")
|
|
617
|
+
? getReferences(sourceMessage)
|
|
618
|
+
: {};
|
|
619
|
+
|
|
620
|
+
const { htmlBody, textBody } = outgoingBody(
|
|
621
|
+
bodyMode,
|
|
622
|
+
body,
|
|
623
|
+
composeLanguage,
|
|
624
|
+
);
|
|
625
|
+
const createdThisAttempt = !outboxMessageId;
|
|
626
|
+
|
|
627
|
+
// The debounce dropped above may have been holding the last two seconds
|
|
628
|
+
// of typing, and an existing entry would otherwise go out as the server
|
|
629
|
+
// last saw it (#674). What is on screen is written first, and a write
|
|
630
|
+
// that fails stops the send rather than transmitting the older copy.
|
|
631
|
+
const flushed = await saveImmediately({
|
|
632
|
+
accountId,
|
|
633
|
+
toAddresses: toAddresses.map((a) => a.email),
|
|
634
|
+
ccAddresses:
|
|
635
|
+
ccAddresses.length > 0
|
|
636
|
+
? ccAddresses.map((a) => a.email)
|
|
637
|
+
: undefined,
|
|
638
|
+
bccAddresses:
|
|
639
|
+
bccAddresses.length > 0
|
|
640
|
+
? bccAddresses.map((a) => a.email)
|
|
641
|
+
: undefined,
|
|
642
|
+
subject: subject || undefined,
|
|
643
|
+
textBody,
|
|
644
|
+
htmlBody,
|
|
645
|
+
...replyData,
|
|
623
646
|
});
|
|
624
|
-
return;
|
|
625
|
-
}
|
|
626
647
|
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
const sent = await sendMutation
|
|
630
|
-
.mutateAsync({
|
|
631
|
-
path: { outboxMessageId: messageId },
|
|
632
|
-
})
|
|
633
|
-
.catch((error: unknown) => {
|
|
648
|
+
if (flushed.outcome === "failed") {
|
|
634
649
|
pushError({
|
|
635
650
|
title: "Couldn't send message",
|
|
636
651
|
detail:
|
|
637
|
-
formatErrorDetail(error) ??
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
: "The send request failed. Try again."),
|
|
641
|
-
error,
|
|
652
|
+
formatErrorDetail(flushed.error) ??
|
|
653
|
+
"Saving the message failed, so nothing was sent. Try again.",
|
|
654
|
+
error: flushed.error,
|
|
642
655
|
});
|
|
643
|
-
return
|
|
644
|
-
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const messageId = flushed.outboxMessageId;
|
|
660
|
+
|
|
661
|
+
const sent = await sendMutation
|
|
662
|
+
.mutateAsync({
|
|
663
|
+
path: { outboxMessageId: messageId },
|
|
664
|
+
})
|
|
665
|
+
.catch((error: unknown) => {
|
|
666
|
+
pushError({
|
|
667
|
+
title: "Couldn't send message",
|
|
668
|
+
detail:
|
|
669
|
+
formatErrorDetail(error) ??
|
|
670
|
+
(createdThisAttempt
|
|
671
|
+
? "The draft was saved but the send request failed. Try again from the Outbox."
|
|
672
|
+
: "The send request failed. Try again."),
|
|
673
|
+
error,
|
|
674
|
+
});
|
|
675
|
+
return null;
|
|
676
|
+
});
|
|
677
|
+
if (sent === null) return;
|
|
678
|
+
|
|
679
|
+
stopAutoSave(messageId);
|
|
680
|
+
startSendPolling(messageId);
|
|
681
|
+
onClose();
|
|
682
|
+
} finally {
|
|
683
|
+
sendInFlightRef.current = false;
|
|
684
|
+
setIsSending(false);
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
[
|
|
688
|
+
toAddresses,
|
|
689
|
+
ccAddresses,
|
|
690
|
+
bccAddresses,
|
|
691
|
+
subject,
|
|
692
|
+
body,
|
|
693
|
+
bodyMode,
|
|
694
|
+
composeLanguage,
|
|
695
|
+
mode,
|
|
696
|
+
sourceMessage,
|
|
697
|
+
outboxMessageId,
|
|
698
|
+
saveImmediately,
|
|
699
|
+
sendMutation,
|
|
700
|
+
stopAutoSave,
|
|
701
|
+
startSendPolling,
|
|
702
|
+
pushError,
|
|
703
|
+
onClose,
|
|
704
|
+
],
|
|
705
|
+
);
|
|
673
706
|
|
|
674
707
|
const handleDiscard = useCallback(() => {
|
|
675
708
|
stopAutoSave(outboxMessageId);
|
|
@@ -681,6 +714,27 @@ export const ComposeForm = ({
|
|
|
681
714
|
onClose();
|
|
682
715
|
}, [stopAutoSave, outboxMessageId, deleteMutation, onClose]);
|
|
683
716
|
|
|
717
|
+
// The refusal is announced first and moved to second. Focus alone carried
|
|
718
|
+
// nothing to a screen reader — the banner above was already on screen, so
|
|
719
|
+
// the press read as the button doing nothing.
|
|
720
|
+
const reportBlocked = useCallback(
|
|
721
|
+
(reason: string) => {
|
|
722
|
+
pushError({ title: "Can't send yet", detail: reason });
|
|
723
|
+
if (reason !== SMTP_MISSING_MESSAGE) return;
|
|
724
|
+
smtpConfigureRef.current?.focus();
|
|
725
|
+
},
|
|
726
|
+
[pushError],
|
|
727
|
+
);
|
|
728
|
+
|
|
729
|
+
const attemptSend = useCallback(() => {
|
|
730
|
+
if (sendReadiness.status === "sending") return;
|
|
731
|
+
if (sendReadiness.status === "blocked") {
|
|
732
|
+
reportBlocked(sendReadiness.reason);
|
|
733
|
+
return;
|
|
734
|
+
}
|
|
735
|
+
void handleSend(sendReadiness.accountId);
|
|
736
|
+
}, [sendReadiness, reportBlocked, handleSend]);
|
|
737
|
+
|
|
684
738
|
const handleAccountChange = useCallback(
|
|
685
739
|
(acct: RemitImapAccountResponse) => {
|
|
686
740
|
setSelectedAccountId(acct.accountId);
|
|
@@ -693,11 +747,15 @@ export const ComposeForm = ({
|
|
|
693
747
|
<ComposeFormShell
|
|
694
748
|
banner={
|
|
695
749
|
selectedAccount && selectedAccountMissingSmtp ? (
|
|
696
|
-
<ComposeSmtpMissingBanner
|
|
750
|
+
<ComposeSmtpMissingBanner
|
|
751
|
+
accountId={selectedAccount.accountId}
|
|
752
|
+
configureRef={smtpConfigureRef}
|
|
753
|
+
/>
|
|
697
754
|
) : undefined
|
|
698
755
|
}
|
|
699
756
|
header={
|
|
700
757
|
<WiredComposeHeader
|
|
758
|
+
documentGeneration={documentGeneration}
|
|
701
759
|
selectedAccountId={selectedAccountId}
|
|
702
760
|
onAccountChange={handleAccountChange}
|
|
703
761
|
toAddresses={toAddresses}
|
|
@@ -725,24 +783,15 @@ export const ComposeForm = ({
|
|
|
725
783
|
}
|
|
726
784
|
actionBar={
|
|
727
785
|
<ComposeActionBar
|
|
728
|
-
|
|
786
|
+
send={sendState}
|
|
787
|
+
onSend={attemptSend}
|
|
788
|
+
onBlocked={reportBlocked}
|
|
729
789
|
onDiscard={handleDiscard}
|
|
730
|
-
sending={isSending}
|
|
731
|
-
canSend={canSend}
|
|
732
790
|
saveStatus={saveStatus}
|
|
733
|
-
unavailableReason={
|
|
734
|
-
selectedAccountMissingSmtp ? "SMTP not configured" : undefined
|
|
735
|
-
}
|
|
736
|
-
onUnavailable={(reason) =>
|
|
737
|
-
pushError({
|
|
738
|
-
title: "Can't send yet",
|
|
739
|
-
detail: reason,
|
|
740
|
-
})
|
|
741
|
-
}
|
|
742
791
|
/>
|
|
743
792
|
}
|
|
744
793
|
>
|
|
745
|
-
<Suspense fallback={<
|
|
794
|
+
<Suspense fallback={<ComposeBodySkeleton />}>
|
|
746
795
|
<LazyComposeBody
|
|
747
796
|
key={documentGeneration}
|
|
748
797
|
mode={bodyMode}
|
|
@@ -750,8 +799,8 @@ export const ComposeForm = ({
|
|
|
750
799
|
initialHtml={initialHtml}
|
|
751
800
|
initialText={initialText}
|
|
752
801
|
onChange={setBody}
|
|
753
|
-
onSubmit={
|
|
754
|
-
|
|
802
|
+
onSubmit={attemptSend}
|
|
803
|
+
initialCaret={mode === "new" ? "start" : undefined}
|
|
755
804
|
onConversionError={pushError}
|
|
756
805
|
languages={accountLanguages}
|
|
757
806
|
initialLanguage={draftLanguage}
|
|
@@ -1,18 +1,22 @@
|
|
|
1
1
|
import { ComposeSmtpMissingBanner as Banner } from "@remit/ui";
|
|
2
2
|
import { useNavigate } from "@tanstack/react-router";
|
|
3
|
+
import type { Ref } from "react";
|
|
3
4
|
|
|
4
5
|
interface ComposeSmtpMissingBannerProps {
|
|
5
6
|
accountId: string;
|
|
7
|
+
configureRef?: Ref<HTMLButtonElement>;
|
|
6
8
|
}
|
|
7
9
|
|
|
8
10
|
/** Sends "Configure SMTP" to the account's settings panel. */
|
|
9
11
|
export const ComposeSmtpMissingBanner = ({
|
|
10
12
|
accountId,
|
|
13
|
+
configureRef,
|
|
11
14
|
}: ComposeSmtpMissingBannerProps) => {
|
|
12
15
|
const navigate = useNavigate();
|
|
13
16
|
|
|
14
17
|
return (
|
|
15
18
|
<Banner
|
|
19
|
+
configureRef={configureRef}
|
|
16
20
|
onConfigure={() => {
|
|
17
21
|
navigate({
|
|
18
22
|
to: "/settings/accounts",
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PR #701 review: the phone header collapsed itself again the moment the
|
|
3
|
+
* software keyboard came back up.
|
|
4
|
+
*
|
|
5
|
+
* Tapping the collapsed line blurs whatever had focus, so the keyboard goes
|
|
6
|
+
* down and the rows appear — but only because the keyboard is down. Reaching
|
|
7
|
+
* for To puts it back up, and the header that had reset itself on the way down
|
|
8
|
+
* collapses over the field being typed into: the input unmounts mid-word, the
|
|
9
|
+
* keyboard drops with it, and the rows come back to start the loop again.
|
|
10
|
+
*
|
|
11
|
+
* What is revealed belongs to the document being written, not to the keyboard,
|
|
12
|
+
* so the keyboard going up and down over it changes nothing.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { afterEach, describe, it } from "node:test";
|
|
17
|
+
import type { RemitImapAccountResponse } from "@remit/api-http-client/types.gen.ts";
|
|
18
|
+
import {
|
|
19
|
+
type AnyRouter,
|
|
20
|
+
createMemoryHistory,
|
|
21
|
+
createRootRoute,
|
|
22
|
+
createRoute,
|
|
23
|
+
createRouter,
|
|
24
|
+
RouterContextProvider,
|
|
25
|
+
} from "@tanstack/react-router";
|
|
26
|
+
import { act, createElement, useEffect } from "react";
|
|
27
|
+
import { createDomHarness, type DomHarness } from "../../test-support/dom";
|
|
28
|
+
import { type HttpMock, mockFetch } from "../../test-support/http";
|
|
29
|
+
import { ComposeForm } from "./ComposeForm";
|
|
30
|
+
import { ComposeProvider, useCompose } from "./ComposeProvider";
|
|
31
|
+
|
|
32
|
+
const ACCOUNT_ID = "acc-1";
|
|
33
|
+
const PHONE = {
|
|
34
|
+
viewportWidth: 390,
|
|
35
|
+
orientation: "portrait",
|
|
36
|
+
pointer: "coarse",
|
|
37
|
+
} as const;
|
|
38
|
+
/** Shrinkage the hook reads as a software keyboard, with room to spare. */
|
|
39
|
+
const KEYBOARD_HEIGHT = 300;
|
|
40
|
+
|
|
41
|
+
const account = {
|
|
42
|
+
accountId: ACCOUNT_ID,
|
|
43
|
+
email: "me@example.com",
|
|
44
|
+
smtpEnabled: true,
|
|
45
|
+
} as unknown as RemitImapAccountResponse;
|
|
46
|
+
|
|
47
|
+
interface VisualViewportStub {
|
|
48
|
+
height: number;
|
|
49
|
+
addEventListener: (type: string, listener: () => void) => void;
|
|
50
|
+
removeEventListener: (type: string, listener: () => void) => void;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const listeners = new Set<() => void>();
|
|
54
|
+
|
|
55
|
+
const viewport: VisualViewportStub = {
|
|
56
|
+
height: 0,
|
|
57
|
+
addEventListener: (_type, listener) => {
|
|
58
|
+
listeners.add(listener);
|
|
59
|
+
},
|
|
60
|
+
removeEventListener: (_type, listener) => {
|
|
61
|
+
listeners.delete(listener);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const setKeyboard = (open: boolean): void => {
|
|
66
|
+
const full = globalThis.window.innerHeight;
|
|
67
|
+
viewport.height = open ? full - KEYBOARD_HEIGHT : full;
|
|
68
|
+
act(() => {
|
|
69
|
+
for (const listener of [...listeners]) listener();
|
|
70
|
+
});
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
let harness: DomHarness | undefined;
|
|
74
|
+
let http: HttpMock | undefined;
|
|
75
|
+
|
|
76
|
+
afterEach(() => {
|
|
77
|
+
harness?.close();
|
|
78
|
+
harness = undefined;
|
|
79
|
+
http?.restore();
|
|
80
|
+
http = undefined;
|
|
81
|
+
listeners.clear();
|
|
82
|
+
Reflect.deleteProperty(globalThis.window, "visualViewport");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
86
|
+
|
|
87
|
+
const rootRoute = createRootRoute();
|
|
88
|
+
const mailboxRoute = createRoute({
|
|
89
|
+
getParentRoute: () => rootRoute,
|
|
90
|
+
path: "/mail/$mailboxId",
|
|
91
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const testRouter = (): AnyRouter =>
|
|
95
|
+
createRouter({
|
|
96
|
+
routeTree: rootRoute.addChildren([mailboxRoute]),
|
|
97
|
+
history: createMemoryHistory({ initialEntries: ["/mail/mbx-1"] }),
|
|
98
|
+
}) as unknown as AnyRouter;
|
|
99
|
+
|
|
100
|
+
const Opened = () => {
|
|
101
|
+
const { state, openCompose } = useCompose();
|
|
102
|
+
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
openCompose({ mode: "new", account });
|
|
105
|
+
}, [openCompose]);
|
|
106
|
+
|
|
107
|
+
if (!state.isOpen) return null;
|
|
108
|
+
|
|
109
|
+
return createElement(ComposeForm, {
|
|
110
|
+
mode: "new",
|
|
111
|
+
account,
|
|
112
|
+
onClose: () => {},
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const mount = async (): Promise<void> => {
|
|
117
|
+
http = mockFetch(async (call) => {
|
|
118
|
+
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
119
|
+
return { items: [] };
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
Object.defineProperty(globalThis.window, "visualViewport", {
|
|
123
|
+
configurable: true,
|
|
124
|
+
value: viewport,
|
|
125
|
+
});
|
|
126
|
+
viewport.height = globalThis.window.innerHeight - KEYBOARD_HEIGHT;
|
|
127
|
+
|
|
128
|
+
harness = createDomHarness(PHONE);
|
|
129
|
+
harness.renderApp(
|
|
130
|
+
createElement(RouterContextProvider, {
|
|
131
|
+
router: testRouter(),
|
|
132
|
+
// biome-ignore lint/correctness/noChildrenProp: RouterContextProvider types `children` as a required prop, which createElement's rest-argument form does not satisfy
|
|
133
|
+
children: createElement(
|
|
134
|
+
ComposeProvider,
|
|
135
|
+
null,
|
|
136
|
+
createElement(Opened, null),
|
|
137
|
+
),
|
|
138
|
+
}),
|
|
139
|
+
);
|
|
140
|
+
await harness.flush();
|
|
141
|
+
await harness.wait(50);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const collapsedBar = (): HTMLElement | null =>
|
|
145
|
+
harness?.query('[data-testid="compose-header-collapsed"]') ?? null;
|
|
146
|
+
|
|
147
|
+
const recipientInput = (): HTMLElement | null =>
|
|
148
|
+
harness?.query("#address-field-To") ?? null;
|
|
149
|
+
|
|
150
|
+
describe("the phone compose header and the software keyboard", () => {
|
|
151
|
+
it("keeps the recipient rows once they are asked for", async () => {
|
|
152
|
+
await mount();
|
|
153
|
+
|
|
154
|
+
const collapsed = collapsedBar();
|
|
155
|
+
assert.ok(
|
|
156
|
+
collapsed,
|
|
157
|
+
"the header stands on one line while the keyboard is up",
|
|
158
|
+
);
|
|
159
|
+
assert.equal(recipientInput(), null);
|
|
160
|
+
|
|
161
|
+
harness?.click(collapsed);
|
|
162
|
+
await harness?.flush();
|
|
163
|
+
assert.ok(recipientInput(), "the rows came back on the tap");
|
|
164
|
+
|
|
165
|
+
// The tap took focus off the body, so the keyboard goes down; reaching for
|
|
166
|
+
// To puts it straight back up.
|
|
167
|
+
setKeyboard(false);
|
|
168
|
+
await harness?.flush();
|
|
169
|
+
setKeyboard(true);
|
|
170
|
+
await harness?.flush();
|
|
171
|
+
|
|
172
|
+
assert.ok(
|
|
173
|
+
recipientInput(),
|
|
174
|
+
"the recipient field survived the keyboard coming back",
|
|
175
|
+
);
|
|
176
|
+
assert.equal(
|
|
177
|
+
collapsedBar(),
|
|
178
|
+
null,
|
|
179
|
+
"the header did not collapse over the field being typed into",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
});
|