@remit/web-client 0.0.180 → 0.0.182
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 +18 -0
- package/src/components/compose/ConversationCompose.tsx +13 -1
- package/src/components/mail/BriefPane.tsx +63 -57
- package/src/components/mail/ConversationView.tsx +7 -12
- package/src/components/mail/FlaggedPane.tsx +63 -57
- package/src/components/mail/IntelligenceDrawer.tsx +44 -0
- package/src/components/mail/MailboxPane.tsx +24 -61
- package/src/components/mail/authenticity-warning.stories.tsx +4 -3
- package/src/components/mail/conversation-reply-identity.render.test.ts +304 -0
- package/src/components/mail/intelligence-surface-reach.render.test.ts +243 -0
- package/src/hooks/useIntelligenceData.test.ts +20 -0
- package/src/hooks/useIntelligenceData.ts +4 -0
- package/src/hooks/useIntelligenceSurface.ts +64 -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.182",
|
|
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": {
|
|
@@ -373,6 +373,24 @@ export const ComposeForm = ({
|
|
|
373
373
|
* the subject the reader edited.
|
|
374
374
|
*/
|
|
375
375
|
const resumedDraftRef = useRef(outboxMessageId !== undefined);
|
|
376
|
+
/**
|
|
377
|
+
* The identity this form has already taken from the surface that mounted it.
|
|
378
|
+
* A reply learns which account the message reached only once the mailbox it
|
|
379
|
+
* was delivered to resolves, which lands after the first render — so From
|
|
380
|
+
* follows the prop instead of only its initial value. Applied once per
|
|
381
|
+
* identity, so a reader who then picks another one keeps it.
|
|
382
|
+
*/
|
|
383
|
+
const appliedAccountIdRef = useRef(account?.accountId);
|
|
384
|
+
|
|
385
|
+
useEffect(() => {
|
|
386
|
+
const accountId = account?.accountId;
|
|
387
|
+
if (!accountId || appliedAccountIdRef.current === accountId) return;
|
|
388
|
+
appliedAccountIdRef.current = accountId;
|
|
389
|
+
// A saved draft carries the account it was written from; that is the one
|
|
390
|
+
// it reopens on.
|
|
391
|
+
if (resumedDraftRef.current) return;
|
|
392
|
+
setSelectedAccountId(accountId);
|
|
393
|
+
}, [account?.accountId]);
|
|
376
394
|
|
|
377
395
|
// The document this form is on changed under it, so it starts again: another
|
|
378
396
|
// draft, or no draft at all. Without this the previous one's fields stay on
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
messageOperationsDescribeMessageOptions,
|
|
4
4
|
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
|
+
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
6
7
|
import {
|
|
7
8
|
type ReplyAddress,
|
|
8
9
|
useAdoptReplyDraft,
|
|
@@ -38,6 +39,17 @@ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
|
|
|
38
39
|
staleTime: Infinity,
|
|
39
40
|
});
|
|
40
41
|
|
|
42
|
+
// The answer leaves from the identity the message reached, so the account is
|
|
43
|
+
// the one owning the mailbox it was delivered to. Taking the head of the
|
|
44
|
+
// configured list answered every account's mail from the first identity, and
|
|
45
|
+
// left the reader's own address in the Cc of a Reply All (#819).
|
|
46
|
+
const { accountId: deliveredToAccountId } = useMailboxAccount(
|
|
47
|
+
sourceMessage?.message.mailboxId,
|
|
48
|
+
);
|
|
49
|
+
const account = config?.accounts?.find(
|
|
50
|
+
(candidate) => candidate.accountId === deliveredToAccountId,
|
|
51
|
+
);
|
|
52
|
+
|
|
41
53
|
return (
|
|
42
54
|
<div className="border-b border-line bg-canvas">
|
|
43
55
|
{/* No key on the draft: the first autosave writes the id it created into
|
|
@@ -46,7 +58,7 @@ export const ConversationCompose = ({ surface }: { surface: ReplyAddress }) => {
|
|
|
46
58
|
<ComposeForm
|
|
47
59
|
layout="flow"
|
|
48
60
|
mode={surface.mode}
|
|
49
|
-
account={
|
|
61
|
+
account={account}
|
|
50
62
|
sourceMessage={sourceMessage}
|
|
51
63
|
outboxMessageId={surface.outboxMessageId}
|
|
52
64
|
onDraftCreated={adoptCreatedDraft}
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import { unifiedThreadOperationsListAllThreadsOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
16
16
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
17
|
-
import { ReadingPaneEmpty
|
|
17
|
+
import { ReadingPaneEmpty } from "@remit/ui";
|
|
18
18
|
import { useQuery } from "@tanstack/react-query";
|
|
19
19
|
import { useNavigate } from "@tanstack/react-router";
|
|
20
20
|
import {
|
|
@@ -24,14 +24,15 @@ import {
|
|
|
24
24
|
useContext,
|
|
25
25
|
useMemo,
|
|
26
26
|
} from "react";
|
|
27
|
-
import { Drawer } from "@/components/layout/Drawer";
|
|
28
27
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
29
28
|
import { DailyBrief } from "@/components/mail/DailyBrief";
|
|
29
|
+
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
30
30
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
31
31
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
32
32
|
import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
|
|
33
33
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
34
34
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
35
|
+
import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
|
|
35
36
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
36
37
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
37
38
|
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
@@ -328,52 +329,64 @@ function BriefList() {
|
|
|
328
329
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
329
330
|
*/
|
|
330
331
|
function BriefReading() {
|
|
331
|
-
const {
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
332
|
+
const {
|
|
333
|
+
conversation,
|
|
334
|
+
selectedThread,
|
|
335
|
+
actions,
|
|
336
|
+
onReply,
|
|
337
|
+
handleDeselectIfRemoved,
|
|
338
|
+
} = useBriefPane();
|
|
337
339
|
const hasThread = Boolean(conversation);
|
|
338
|
-
const
|
|
340
|
+
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
339
341
|
|
|
340
342
|
return (
|
|
341
|
-
|
|
342
|
-
<
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
343
|
+
<>
|
|
344
|
+
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
345
|
+
<MessageToolbar
|
|
346
|
+
hasThread={hasThread}
|
|
347
|
+
intelligenceOpen={intelligence.isShowing}
|
|
348
|
+
canToggleIntelligence={intelligence.canToggle}
|
|
349
|
+
onToggleIntelligence={intelligence.toggle}
|
|
350
|
+
onReply={onReply ? () => onReply("reply") : undefined}
|
|
351
|
+
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
352
|
+
onForward={onReply ? () => onReply("forward") : undefined}
|
|
353
|
+
onDelete={hasThread ? actions.deleteThread : undefined}
|
|
354
|
+
onToggleStar={hasThread ? actions.toggleStar : undefined}
|
|
355
|
+
isStarred={actions.isStarred}
|
|
356
|
+
moveContext={
|
|
357
|
+
hasThread && actions.accountId && actions.mailboxId
|
|
358
|
+
? {
|
|
359
|
+
accountId: actions.accountId,
|
|
360
|
+
currentMailboxId: actions.mailboxId,
|
|
361
|
+
onMove: actions.moveThread,
|
|
362
|
+
}
|
|
363
|
+
: undefined
|
|
364
|
+
}
|
|
365
|
+
/>
|
|
366
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
367
|
+
{conversation ? (
|
|
368
|
+
<ConversationView
|
|
369
|
+
threadId={conversation.threadId}
|
|
370
|
+
mailboxId={conversation.mailboxId}
|
|
371
|
+
subject={conversation.subject}
|
|
372
|
+
selectedMessageId={conversation.messageId}
|
|
373
|
+
authenticity={conversation.authenticity}
|
|
374
|
+
onOpenIntelligence={intelligence.open}
|
|
375
|
+
/>
|
|
376
|
+
) : (
|
|
377
|
+
<ReadingPaneEmpty />
|
|
378
|
+
)}
|
|
379
|
+
</div>
|
|
380
|
+
</section>
|
|
381
|
+
<IntelligenceDrawer
|
|
382
|
+
isOpen={intelligence.drawerOpen}
|
|
383
|
+
onClose={intelligence.closeDrawer}
|
|
384
|
+
thread={selectedThread}
|
|
385
|
+
mailboxId={selectedThread?.mailboxId}
|
|
386
|
+
accountId={selectedThread?.accountId}
|
|
387
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
362
388
|
/>
|
|
363
|
-
|
|
364
|
-
{conversation ? (
|
|
365
|
-
<ConversationView
|
|
366
|
-
threadId={conversation.threadId}
|
|
367
|
-
mailboxId={conversation.mailboxId}
|
|
368
|
-
subject={conversation.subject}
|
|
369
|
-
selectedMessageId={conversation.messageId}
|
|
370
|
-
authenticity={conversation.authenticity}
|
|
371
|
-
/>
|
|
372
|
-
) : (
|
|
373
|
-
<ReadingPaneEmpty />
|
|
374
|
-
)}
|
|
375
|
-
</div>
|
|
376
|
-
</section>
|
|
389
|
+
</>
|
|
377
390
|
);
|
|
378
391
|
}
|
|
379
392
|
|
|
@@ -428,21 +441,14 @@ function BriefPhone() {
|
|
|
428
441
|
}
|
|
429
442
|
mobileIntelligenceOpen={drawer.isOpen}
|
|
430
443
|
/>
|
|
431
|
-
<
|
|
444
|
+
<IntelligenceDrawer
|
|
432
445
|
isOpen={drawer.isOpen}
|
|
433
446
|
onClose={drawer.close}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
thread={selectedThread}
|
|
440
|
-
mailboxId={selectedThread?.mailboxId}
|
|
441
|
-
accountId={selectedThread?.accountId}
|
|
442
|
-
hideCloseButton
|
|
443
|
-
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
444
|
-
/>
|
|
445
|
-
</Drawer>
|
|
447
|
+
thread={selectedThread}
|
|
448
|
+
mailboxId={selectedThread?.mailboxId}
|
|
449
|
+
accountId={selectedThread?.accountId}
|
|
450
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
451
|
+
/>
|
|
446
452
|
</>
|
|
447
453
|
);
|
|
448
454
|
}
|
|
@@ -1,7 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
configOperationsGetConfigOptions,
|
|
3
|
-
threadDetailOperationsListThreadMessagesOptions,
|
|
4
|
-
} from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
1
|
+
import { threadDetailOperationsListThreadMessagesOptions } from "@remit/api-http-client/@tanstack/react-query.gen.ts";
|
|
5
2
|
import type { RemitImapMessageAuthenticity } from "@remit/api-http-client/types.gen.ts";
|
|
6
3
|
import { MobileReadingPane } from "@remit/ui";
|
|
7
4
|
import { useQuery } from "@tanstack/react-query";
|
|
@@ -196,19 +193,17 @@ export const ConversationView = ({
|
|
|
196
193
|
}
|
|
197
194
|
}, [orderedMessages, focusedIndex, toggleExpanded]);
|
|
198
195
|
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
// Mark messages as read immediately when expanded.
|
|
196
|
+
// Mark messages as read immediately when expanded. The unread badge that has
|
|
197
|
+
// to be refreshed belongs to the account this mailbox is in, so the
|
|
198
|
+
// invalidation is scoped to that one rather than to the first configured
|
|
199
|
+
// account, whose list was being refetched while the right one went stale
|
|
200
|
+
// (#819).
|
|
206
201
|
useMarkAsRead({
|
|
207
202
|
messages,
|
|
208
203
|
expandedIds,
|
|
209
204
|
threadId,
|
|
210
205
|
mailboxId,
|
|
211
|
-
accountId:
|
|
206
|
+
accountId: mailboxAccountId,
|
|
212
207
|
});
|
|
213
208
|
|
|
214
209
|
// Star toggle functionality
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* On phone, use `<FlaggedPane.Phone />` instead.
|
|
26
26
|
*/
|
|
27
27
|
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
28
|
-
import { ReadingPaneEmpty
|
|
28
|
+
import { ReadingPaneEmpty } from "@remit/ui";
|
|
29
29
|
import { useNavigate } from "@tanstack/react-router";
|
|
30
30
|
import {
|
|
31
31
|
createContext,
|
|
@@ -34,14 +34,15 @@ import {
|
|
|
34
34
|
useContext,
|
|
35
35
|
useMemo,
|
|
36
36
|
} from "react";
|
|
37
|
-
import { Drawer } from "@/components/layout/Drawer";
|
|
38
37
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
39
38
|
import { FlaggedList } from "@/components/mail/FlaggedList";
|
|
39
|
+
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
40
40
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
41
41
|
import { MessageToolbar } from "@/components/mail/MessageToolbar";
|
|
42
42
|
import type { OpenMessageOptions } from "@/components/mail/ThreadListInteraction";
|
|
43
43
|
import { useDeleteMessages } from "@/hooks/useDeleteMessages";
|
|
44
44
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
45
|
+
import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
|
|
45
46
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
46
47
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
47
48
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
@@ -331,52 +332,64 @@ function FlaggedListSlot() {
|
|
|
331
332
|
* Mount in the `reading` slot of `AppShellSlotted`. Only rendered ≥ 1024px.
|
|
332
333
|
*/
|
|
333
334
|
function FlaggedReading() {
|
|
334
|
-
const {
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
335
|
+
const {
|
|
336
|
+
conversation,
|
|
337
|
+
selectedThread,
|
|
338
|
+
actions,
|
|
339
|
+
onReply,
|
|
340
|
+
handleDeselectIfRemoved,
|
|
341
|
+
} = useFlaggedPane();
|
|
340
342
|
const hasThread = Boolean(conversation);
|
|
341
|
-
const
|
|
343
|
+
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
342
344
|
|
|
343
345
|
return (
|
|
344
|
-
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
346
|
+
<>
|
|
347
|
+
<section className="flex h-full w-full min-w-0 flex-col bg-canvas">
|
|
348
|
+
<MessageToolbar
|
|
349
|
+
hasThread={hasThread}
|
|
350
|
+
intelligenceOpen={intelligence.isShowing}
|
|
351
|
+
canToggleIntelligence={intelligence.canToggle}
|
|
352
|
+
onToggleIntelligence={intelligence.toggle}
|
|
353
|
+
onReply={onReply ? () => onReply("reply") : undefined}
|
|
354
|
+
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
355
|
+
onForward={onReply ? () => onReply("forward") : undefined}
|
|
356
|
+
onDelete={hasThread ? actions.deleteThread : undefined}
|
|
357
|
+
onToggleStar={hasThread ? actions.toggleStar : undefined}
|
|
358
|
+
isStarred={actions.isStarred}
|
|
359
|
+
moveContext={
|
|
360
|
+
hasThread && actions.accountId && actions.mailboxId
|
|
361
|
+
? {
|
|
362
|
+
accountId: actions.accountId,
|
|
363
|
+
currentMailboxId: actions.mailboxId,
|
|
364
|
+
onMove: actions.moveThread,
|
|
365
|
+
}
|
|
366
|
+
: undefined
|
|
367
|
+
}
|
|
368
|
+
/>
|
|
369
|
+
<div className="min-h-0 flex-1 overflow-hidden">
|
|
370
|
+
{conversation ? (
|
|
371
|
+
<ConversationView
|
|
372
|
+
threadId={conversation.threadId}
|
|
373
|
+
mailboxId={conversation.mailboxId}
|
|
374
|
+
subject={conversation.subject}
|
|
375
|
+
selectedMessageId={conversation.messageId}
|
|
376
|
+
authenticity={conversation.authenticity}
|
|
377
|
+
onOpenIntelligence={intelligence.open}
|
|
378
|
+
/>
|
|
379
|
+
) : (
|
|
380
|
+
<ReadingPaneEmpty />
|
|
381
|
+
)}
|
|
382
|
+
</div>
|
|
383
|
+
</section>
|
|
384
|
+
<IntelligenceDrawer
|
|
385
|
+
isOpen={intelligence.drawerOpen}
|
|
386
|
+
onClose={intelligence.closeDrawer}
|
|
387
|
+
thread={selectedThread}
|
|
388
|
+
mailboxId={selectedThread?.mailboxId}
|
|
389
|
+
accountId={selectedThread?.accountId}
|
|
390
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
365
391
|
/>
|
|
366
|
-
|
|
367
|
-
{conversation ? (
|
|
368
|
-
<ConversationView
|
|
369
|
-
threadId={conversation.threadId}
|
|
370
|
-
mailboxId={conversation.mailboxId}
|
|
371
|
-
subject={conversation.subject}
|
|
372
|
-
selectedMessageId={conversation.messageId}
|
|
373
|
-
authenticity={conversation.authenticity}
|
|
374
|
-
/>
|
|
375
|
-
) : (
|
|
376
|
-
<ReadingPaneEmpty />
|
|
377
|
-
)}
|
|
378
|
-
</div>
|
|
379
|
-
</section>
|
|
392
|
+
</>
|
|
380
393
|
);
|
|
381
394
|
}
|
|
382
395
|
|
|
@@ -429,21 +442,14 @@ function FlaggedPhone() {
|
|
|
429
442
|
}
|
|
430
443
|
mobileIntelligenceOpen={drawer.isOpen}
|
|
431
444
|
/>
|
|
432
|
-
<
|
|
445
|
+
<IntelligenceDrawer
|
|
433
446
|
isOpen={drawer.isOpen}
|
|
434
447
|
onClose={drawer.close}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
thread={selectedThread}
|
|
441
|
-
mailboxId={selectedThread?.mailboxId}
|
|
442
|
-
accountId={selectedThread?.accountId}
|
|
443
|
-
hideCloseButton
|
|
444
|
-
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
445
|
-
/>
|
|
446
|
-
</Drawer>
|
|
448
|
+
thread={selectedThread}
|
|
449
|
+
mailboxId={selectedThread?.mailboxId}
|
|
450
|
+
accountId={selectedThread?.accountId}
|
|
451
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
452
|
+
/>
|
|
447
453
|
</>
|
|
448
454
|
);
|
|
449
455
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { RemitImapThreadMessageResponse } from "@remit/api-http-client/types.gen.ts";
|
|
2
|
+
import { Drawer } from "@/components/layout/Drawer";
|
|
3
|
+
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The intelligence pane as a modal drawer — the surface every list pane uses
|
|
7
|
+
* wherever the rail has no room, which is the phone and the two-pane desktop
|
|
8
|
+
* band between 1024 and 1280px. Its open state is the caller's
|
|
9
|
+
* (`useIntelligenceSurface`); what is inside it is the same panel the rail
|
|
10
|
+
* mounts, minus its own close, because the drawer's header already carries one.
|
|
11
|
+
*/
|
|
12
|
+
export function IntelligenceDrawer({
|
|
13
|
+
isOpen,
|
|
14
|
+
onClose,
|
|
15
|
+
thread,
|
|
16
|
+
mailboxId,
|
|
17
|
+
accountId,
|
|
18
|
+
onAfterOptimisticRemove,
|
|
19
|
+
}: {
|
|
20
|
+
isOpen: boolean;
|
|
21
|
+
onClose: () => void;
|
|
22
|
+
thread?: RemitImapThreadMessageResponse;
|
|
23
|
+
mailboxId?: string;
|
|
24
|
+
accountId?: string;
|
|
25
|
+
onAfterOptimisticRemove?: (messageIds: string[]) => void;
|
|
26
|
+
}) {
|
|
27
|
+
return (
|
|
28
|
+
<Drawer
|
|
29
|
+
isOpen={isOpen}
|
|
30
|
+
onClose={onClose}
|
|
31
|
+
ariaLabel="Message details"
|
|
32
|
+
side="right"
|
|
33
|
+
>
|
|
34
|
+
<IntelligencePane
|
|
35
|
+
onClose={onClose}
|
|
36
|
+
thread={thread}
|
|
37
|
+
mailboxId={mailboxId}
|
|
38
|
+
accountId={accountId}
|
|
39
|
+
hideCloseButton
|
|
40
|
+
onAfterOptimisticRemove={onAfterOptimisticRemove}
|
|
41
|
+
/>
|
|
42
|
+
</Drawer>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
@@ -31,7 +31,6 @@ import {
|
|
|
31
31
|
RefreshButton,
|
|
32
32
|
type RescueCandidate,
|
|
33
33
|
type SearchResult,
|
|
34
|
-
useAppShellLayout,
|
|
35
34
|
} from "@remit/ui";
|
|
36
35
|
import { useInfiniteQuery } from "@tanstack/react-query";
|
|
37
36
|
import { useNavigate } from "@tanstack/react-router";
|
|
@@ -46,9 +45,9 @@ import {
|
|
|
46
45
|
useRef,
|
|
47
46
|
useState,
|
|
48
47
|
} from "react";
|
|
49
|
-
import { Drawer } from "@/components/layout/Drawer";
|
|
50
48
|
import { ConversationView } from "@/components/mail/ConversationView";
|
|
51
49
|
import { DraftsView } from "@/components/mail/DraftsView";
|
|
50
|
+
import { IntelligenceDrawer } from "@/components/mail/IntelligenceDrawer";
|
|
52
51
|
import { IntelligencePane } from "@/components/mail/IntelligencePane";
|
|
53
52
|
import {
|
|
54
53
|
MessageList,
|
|
@@ -73,6 +72,7 @@ import {
|
|
|
73
72
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
74
73
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
75
74
|
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
75
|
+
import { useIntelligenceSurface } from "@/hooks/useIntelligenceSurface";
|
|
76
76
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
77
77
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
78
78
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -1059,19 +1059,14 @@ function MailboxReading() {
|
|
|
1059
1059
|
mailboxAccountLoading,
|
|
1060
1060
|
selectedThread,
|
|
1061
1061
|
conversation,
|
|
1062
|
-
intelligenceOpen,
|
|
1063
|
-
onToggleIntelligence,
|
|
1064
1062
|
onReply,
|
|
1065
1063
|
onToolbarDelete,
|
|
1066
1064
|
onToolbarStar,
|
|
1067
1065
|
onToolbarMove,
|
|
1068
1066
|
handleDeselectIfRemoved,
|
|
1069
1067
|
} = useMailboxPane();
|
|
1070
|
-
// Which surface intelligence has here: the rail between 1280 and up, the
|
|
1071
|
-
// mobile drawer below that, where the reading pane is mounted and the rail
|
|
1072
|
-
// has no room.
|
|
1073
|
-
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
1074
1068
|
const hasThread = Boolean(conversation);
|
|
1069
|
+
const intelligence = useIntelligenceSurface(conversation?.threadId);
|
|
1075
1070
|
|
|
1076
1071
|
// The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
|
|
1077
1072
|
// own width gate, because raising it writes `#intelligence` into the address
|
|
@@ -1079,6 +1074,7 @@ function MailboxReading() {
|
|
|
1079
1074
|
// opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
|
|
1080
1075
|
// banner is the announcement and its "Why?" is the way in.
|
|
1081
1076
|
const autoOpenedForRef = useRef<string | null>(null);
|
|
1077
|
+
const { railFits, openRail } = intelligence;
|
|
1082
1078
|
useEffect(() => {
|
|
1083
1079
|
if (!railFits) return;
|
|
1084
1080
|
const id = selectedThread?.messageId ?? null;
|
|
@@ -1086,31 +1082,14 @@ function MailboxReading() {
|
|
|
1086
1082
|
if (autoOpenedForRef.current === id) return;
|
|
1087
1083
|
if (!selectedThread?.authenticity?.dkimMismatch) return;
|
|
1088
1084
|
autoOpenedForRef.current = id;
|
|
1089
|
-
|
|
1085
|
+
openRail();
|
|
1090
1086
|
}, [
|
|
1091
1087
|
railFits,
|
|
1088
|
+
openRail,
|
|
1092
1089
|
selectedThread?.messageId,
|
|
1093
1090
|
selectedThread?.authenticity?.dkimMismatch,
|
|
1094
|
-
intelligenceOpen,
|
|
1095
|
-
onToggleIntelligence,
|
|
1096
1091
|
]);
|
|
1097
1092
|
|
|
1098
|
-
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1099
|
-
const drawerOpen = !railFits && drawer.isOpen;
|
|
1100
|
-
const closeIntelligenceDrawer = drawer.close;
|
|
1101
|
-
const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
|
|
1102
|
-
// The toolbar's control, which toggles whichever surface this width has.
|
|
1103
|
-
const { toggle: toggleDrawer } = drawer;
|
|
1104
|
-
const toggleIntelligence = useCallback(() => {
|
|
1105
|
-
if (railFits) {
|
|
1106
|
-
onToggleIntelligence();
|
|
1107
|
-
return;
|
|
1108
|
-
}
|
|
1109
|
-
toggleDrawer();
|
|
1110
|
-
}, [railFits, onToggleIntelligence, toggleDrawer]);
|
|
1111
|
-
const intelligenceShowing =
|
|
1112
|
-
hasThread && (railFits ? intelligenceOpen : drawerOpen);
|
|
1113
|
-
|
|
1114
1093
|
const detailPane = conversation ? (
|
|
1115
1094
|
<ConversationView
|
|
1116
1095
|
threadId={conversation.threadId}
|
|
@@ -1118,9 +1097,7 @@ function MailboxReading() {
|
|
|
1118
1097
|
subject={conversation.subject}
|
|
1119
1098
|
selectedMessageId={conversation.messageId}
|
|
1120
1099
|
authenticity={conversation.authenticity}
|
|
1121
|
-
onOpenIntelligence={
|
|
1122
|
-
conversation.authenticity?.dkimMismatch ? openIntelligence : undefined
|
|
1123
|
-
}
|
|
1100
|
+
onOpenIntelligence={intelligence.open}
|
|
1124
1101
|
/>
|
|
1125
1102
|
) : (
|
|
1126
1103
|
<ReadingPaneEmpty />
|
|
@@ -1132,9 +1109,9 @@ function MailboxReading() {
|
|
|
1132
1109
|
<MessageToolbar
|
|
1133
1110
|
hasThread={hasThread}
|
|
1134
1111
|
messageId={conversation?.messageId}
|
|
1135
|
-
intelligenceOpen={
|
|
1136
|
-
canToggleIntelligence={
|
|
1137
|
-
onToggleIntelligence={
|
|
1112
|
+
intelligenceOpen={intelligence.isShowing}
|
|
1113
|
+
canToggleIntelligence={intelligence.canToggle}
|
|
1114
|
+
onToggleIntelligence={intelligence.toggle}
|
|
1138
1115
|
onReply={onReply ? () => onReply("reply") : undefined}
|
|
1139
1116
|
onReplyAll={onReply ? () => onReply("reply-all") : undefined}
|
|
1140
1117
|
onForward={onReply ? () => onReply("forward") : undefined}
|
|
@@ -1154,21 +1131,14 @@ function MailboxReading() {
|
|
|
1154
1131
|
/>
|
|
1155
1132
|
<div className="min-h-0 flex-1 overflow-hidden">{detailPane}</div>
|
|
1156
1133
|
</section>
|
|
1157
|
-
<
|
|
1158
|
-
isOpen={drawerOpen}
|
|
1159
|
-
onClose={
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
thread={selectedThread}
|
|
1166
|
-
mailboxId={mailboxId}
|
|
1167
|
-
accountId={mailboxAccountId}
|
|
1168
|
-
hideCloseButton
|
|
1169
|
-
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1170
|
-
/>
|
|
1171
|
-
</Drawer>
|
|
1134
|
+
<IntelligenceDrawer
|
|
1135
|
+
isOpen={intelligence.drawerOpen}
|
|
1136
|
+
onClose={intelligence.closeDrawer}
|
|
1137
|
+
thread={selectedThread}
|
|
1138
|
+
mailboxId={mailboxId}
|
|
1139
|
+
accountId={mailboxAccountId}
|
|
1140
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1141
|
+
/>
|
|
1172
1142
|
</>
|
|
1173
1143
|
);
|
|
1174
1144
|
}
|
|
@@ -1232,21 +1202,14 @@ function MailboxPhone() {
|
|
|
1232
1202
|
}
|
|
1233
1203
|
mobileIntelligenceOpen={drawer.isOpen}
|
|
1234
1204
|
/>
|
|
1235
|
-
<
|
|
1205
|
+
<IntelligenceDrawer
|
|
1236
1206
|
isOpen={drawer.isOpen}
|
|
1237
1207
|
onClose={drawer.close}
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
thread={selectedThread}
|
|
1244
|
-
mailboxId={mailboxId}
|
|
1245
|
-
accountId={mailboxAccountId}
|
|
1246
|
-
hideCloseButton
|
|
1247
|
-
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1248
|
-
/>
|
|
1249
|
-
</Drawer>
|
|
1208
|
+
thread={selectedThread}
|
|
1209
|
+
mailboxId={mailboxId}
|
|
1210
|
+
accountId={mailboxAccountId}
|
|
1211
|
+
onAfterOptimisticRemove={handleDeselectIfRemoved}
|
|
1212
|
+
/>
|
|
1250
1213
|
</>
|
|
1251
1214
|
);
|
|
1252
1215
|
}
|
|
@@ -9,7 +9,8 @@ import { AuthenticityBanner } from "@/components/mail/AuthenticityBanner";
|
|
|
9
9
|
*
|
|
10
10
|
* Between 1024 and 1280px the shell has room for the reading pane but not the
|
|
11
11
|
* intelligence rail, so the link opens the same right-anchored drawer the phone
|
|
12
|
-
* uses
|
|
12
|
+
* uses. Every list pane mounts it wherever the rail does not fit — the mailbox,
|
|
13
|
+
* the brief and Flagged all read that from `useIntelligenceSurface` (#817). The
|
|
13
14
|
* warning itself reads as one sentence: the link sits in the text flow at the
|
|
14
15
|
* body's own size rather than floating off to the right as chrome.
|
|
15
16
|
*/
|
|
@@ -73,8 +74,8 @@ const intelligence: IntelligenceData = {
|
|
|
73
74
|
};
|
|
74
75
|
|
|
75
76
|
/**
|
|
76
|
-
* The reading pane as
|
|
77
|
-
*
|
|
77
|
+
* The reading pane as every list pane composes it at this width: the banner
|
|
78
|
+
* above the thread body, and the drawer it opens over the panes.
|
|
78
79
|
*/
|
|
79
80
|
const TwoPaneReading = () => {
|
|
80
81
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which identity answers a message, on an instance holding more than one.
|
|
3
|
+
*
|
|
4
|
+
* The account a message reached is the mailbox it was delivered to, so the
|
|
5
|
+
* reply leaves from that identity, the Reply All keeps that identity out of
|
|
6
|
+
* its own Cc, and reading the message refreshes that account's folder list.
|
|
7
|
+
* All three read the first configured account before (#819), which answered
|
|
8
|
+
* every account's mail as the first one.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, describe, it } from "node:test";
|
|
13
|
+
import type {
|
|
14
|
+
RemitImapDescribeMessageResponse,
|
|
15
|
+
RemitImapThreadMessageResponse,
|
|
16
|
+
} from "@remit/api-http-client/types.gen.ts";
|
|
17
|
+
import {
|
|
18
|
+
type AnyRouter,
|
|
19
|
+
createMemoryHistory,
|
|
20
|
+
createRootRoute,
|
|
21
|
+
createRoute,
|
|
22
|
+
createRouter,
|
|
23
|
+
Outlet,
|
|
24
|
+
RouterProvider,
|
|
25
|
+
} from "@tanstack/react-router";
|
|
26
|
+
import { createElement } from "react";
|
|
27
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
28
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
29
|
+
import {
|
|
30
|
+
makeAccount,
|
|
31
|
+
makeMailbox,
|
|
32
|
+
makeThreadMessage,
|
|
33
|
+
} from "@/test-support/fixtures";
|
|
34
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
35
|
+
import { MARK_READ_DELAY_MS } from "../../hooks/useMarkAsRead";
|
|
36
|
+
import { ConversationView } from "./ConversationView";
|
|
37
|
+
|
|
38
|
+
const FIRST_ACCOUNT_ID = "acc-first";
|
|
39
|
+
const REACHED_ACCOUNT_ID = "acc-reached";
|
|
40
|
+
const FIRST_MAILBOX_ID = "mbx-first-inbox";
|
|
41
|
+
const REACHED_MAILBOX_ID = "mbx-reached-inbox";
|
|
42
|
+
const THREAD_ID = "thread-1";
|
|
43
|
+
const MESSAGE_ID = "msg-1";
|
|
44
|
+
|
|
45
|
+
const REACHED_EMAIL = "bob@example.com";
|
|
46
|
+
|
|
47
|
+
// Two identities, in the order the config hands them over. The message below
|
|
48
|
+
// reached the second one, so nothing about it can be answered off the head of
|
|
49
|
+
// this list.
|
|
50
|
+
const firstAccount = makeAccount({
|
|
51
|
+
accountId: FIRST_ACCOUNT_ID,
|
|
52
|
+
email: "alice@example.com",
|
|
53
|
+
username: "alice@example.com",
|
|
54
|
+
smtpUsername: "alice@example.com",
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const reachedAccount = makeAccount({
|
|
58
|
+
accountId: REACHED_ACCOUNT_ID,
|
|
59
|
+
email: REACHED_EMAIL,
|
|
60
|
+
username: REACHED_EMAIL,
|
|
61
|
+
smtpUsername: REACHED_EMAIL,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const firstMailbox = makeMailbox({
|
|
65
|
+
mailboxId: FIRST_MAILBOX_ID,
|
|
66
|
+
accountId: FIRST_ACCOUNT_ID,
|
|
67
|
+
fullPath: "INBOX",
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
const reachedMailbox = makeMailbox({
|
|
71
|
+
mailboxId: REACHED_MAILBOX_ID,
|
|
72
|
+
accountId: REACHED_ACCOUNT_ID,
|
|
73
|
+
fullPath: "INBOX",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const threadMessage = (isRead: boolean): RemitImapThreadMessageResponse =>
|
|
77
|
+
makeThreadMessage({
|
|
78
|
+
messageId: MESSAGE_ID,
|
|
79
|
+
threadId: THREAD_ID,
|
|
80
|
+
mailboxId: REACHED_MAILBOX_ID,
|
|
81
|
+
accountId: REACHED_ACCOUNT_ID,
|
|
82
|
+
subject: "Lunch Thursday?",
|
|
83
|
+
fromName: "Ada Lovelace",
|
|
84
|
+
fromEmail: "ada@example.com",
|
|
85
|
+
isRead,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Sent to the second identity, with a third party alongside it. A Reply All
|
|
89
|
+
// answers Ada and keeps Carol; the reader's own address is the one address that
|
|
90
|
+
// must not come back in the Cc.
|
|
91
|
+
const describeMessage: RemitImapDescribeMessageResponse = {
|
|
92
|
+
message: {
|
|
93
|
+
messageId: MESSAGE_ID,
|
|
94
|
+
mailboxId: REACHED_MAILBOX_ID,
|
|
95
|
+
uid: 1,
|
|
96
|
+
rfc822Size: 512,
|
|
97
|
+
internalDate: 1_767_225_600_000,
|
|
98
|
+
},
|
|
99
|
+
envelope: {
|
|
100
|
+
messageId: MESSAGE_ID,
|
|
101
|
+
date: 1_767_225_600_000,
|
|
102
|
+
subject: "Lunch Thursday?",
|
|
103
|
+
messageIdValue: "<ada-1@example.com>",
|
|
104
|
+
from: [
|
|
105
|
+
{
|
|
106
|
+
addressId: "addr-ada",
|
|
107
|
+
displayName: "Ada Lovelace",
|
|
108
|
+
normalizedEmail: "ada@example.com",
|
|
109
|
+
addressRole: "from",
|
|
110
|
+
addressOrder: 0,
|
|
111
|
+
},
|
|
112
|
+
],
|
|
113
|
+
to: [
|
|
114
|
+
{
|
|
115
|
+
addressId: "addr-reached",
|
|
116
|
+
normalizedEmail: REACHED_EMAIL,
|
|
117
|
+
addressRole: "to",
|
|
118
|
+
addressOrder: 0,
|
|
119
|
+
},
|
|
120
|
+
],
|
|
121
|
+
cc: [
|
|
122
|
+
{
|
|
123
|
+
addressId: "addr-carol",
|
|
124
|
+
normalizedEmail: "carol@example.com",
|
|
125
|
+
addressRole: "cc",
|
|
126
|
+
addressOrder: 0,
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
bcc: [],
|
|
130
|
+
replyTo: [],
|
|
131
|
+
category: "uncategorized",
|
|
132
|
+
senderTrust: "unknown",
|
|
133
|
+
},
|
|
134
|
+
flags: ["\\Seen"],
|
|
135
|
+
bodyParts: [],
|
|
136
|
+
references: [],
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
let harness: DomHarness | undefined;
|
|
140
|
+
let http: HttpMock | undefined;
|
|
141
|
+
|
|
142
|
+
afterEach(() => {
|
|
143
|
+
harness?.close();
|
|
144
|
+
harness = undefined;
|
|
145
|
+
http?.restore();
|
|
146
|
+
http = undefined;
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
150
|
+
// `window`.
|
|
151
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
152
|
+
|
|
153
|
+
const MESSAGE_PATH = `/mail/${REACHED_MAILBOX_ID}/${THREAD_ID}/${MESSAGE_ID}`;
|
|
154
|
+
|
|
155
|
+
const testRouter = (href: string): AnyRouter => {
|
|
156
|
+
const rootRoute = createRootRoute({
|
|
157
|
+
component: () =>
|
|
158
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
159
|
+
});
|
|
160
|
+
const mailboxRoute = createRoute({
|
|
161
|
+
getParentRoute: () => rootRoute,
|
|
162
|
+
path: "/mail/$mailboxId",
|
|
163
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
164
|
+
});
|
|
165
|
+
const threadRoute = createRoute({
|
|
166
|
+
getParentRoute: () => mailboxRoute,
|
|
167
|
+
path: "$threadId",
|
|
168
|
+
});
|
|
169
|
+
const messageRoute = createRoute({
|
|
170
|
+
getParentRoute: () => threadRoute,
|
|
171
|
+
path: "$messageId",
|
|
172
|
+
component: () =>
|
|
173
|
+
createElement(ConversationView, {
|
|
174
|
+
threadId: THREAD_ID,
|
|
175
|
+
mailboxId: REACHED_MAILBOX_ID,
|
|
176
|
+
subject: "Lunch Thursday?",
|
|
177
|
+
selectedMessageId: MESSAGE_ID,
|
|
178
|
+
}),
|
|
179
|
+
});
|
|
180
|
+
const replyRoute = createRoute({
|
|
181
|
+
getParentRoute: () => messageRoute,
|
|
182
|
+
path: "$mode/{-$outboxMessageId}",
|
|
183
|
+
});
|
|
184
|
+
const routeTree = rootRoute.addChildren([
|
|
185
|
+
mailboxRoute.addChildren([
|
|
186
|
+
threadRoute.addChildren([messageRoute.addChildren([replyRoute])]),
|
|
187
|
+
]),
|
|
188
|
+
]);
|
|
189
|
+
return createRouter({
|
|
190
|
+
routeTree,
|
|
191
|
+
history: createMemoryHistory({ initialEntries: [href] }),
|
|
192
|
+
}) as unknown as AnyRouter;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const mountAt = async (
|
|
196
|
+
href: string,
|
|
197
|
+
{ isRead = true }: { isRead?: boolean } = {},
|
|
198
|
+
): Promise<DomHarness> => {
|
|
199
|
+
const messages = [threadMessage(isRead)];
|
|
200
|
+
http = mockFetch((call) => {
|
|
201
|
+
if (call.path.endsWith("/config")) {
|
|
202
|
+
return { accounts: [firstAccount, reachedAccount] };
|
|
203
|
+
}
|
|
204
|
+
if (call.path.endsWith(`/accounts/${FIRST_ACCOUNT_ID}/mailboxes`)) {
|
|
205
|
+
return { items: [firstMailbox] };
|
|
206
|
+
}
|
|
207
|
+
if (call.path.endsWith(`/accounts/${REACHED_ACCOUNT_ID}/mailboxes`)) {
|
|
208
|
+
return { items: [reachedMailbox] };
|
|
209
|
+
}
|
|
210
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
211
|
+
return { items: messages };
|
|
212
|
+
}
|
|
213
|
+
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
214
|
+
return { items: [] };
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const router = testRouter(href);
|
|
218
|
+
await router.load();
|
|
219
|
+
harness = createDomHarness();
|
|
220
|
+
harness.renderApp(createElement(RouterProvider, { router }));
|
|
221
|
+
await harness.flush();
|
|
222
|
+
await harness.wait(20);
|
|
223
|
+
await harness.flush();
|
|
224
|
+
return harness;
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** The identity the From row is standing on. */
|
|
228
|
+
const chosenIdentity = (mounted: DomHarness): string => {
|
|
229
|
+
const selector = mounted.query<HTMLSelectElement>("#from-account-selector");
|
|
230
|
+
assert.ok(selector, "the From row offers the configured identities");
|
|
231
|
+
return selector.value;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
/** The addresses held as chips in one of the composer's recipient fields. */
|
|
235
|
+
const recipients = (mounted: DomHarness, label: string): string[] => {
|
|
236
|
+
const input = mounted.query(`#address-field-${label}`);
|
|
237
|
+
assert.ok(input, `the ${label} field is on screen`);
|
|
238
|
+
const field = input.parentElement;
|
|
239
|
+
assert.ok(field, `the ${label} field holds its chips`);
|
|
240
|
+
return [...field.querySelectorAll("[aria-label^='Remove ']")].map((button) =>
|
|
241
|
+
(button.getAttribute("aria-label") ?? "").replace("Remove ", ""),
|
|
242
|
+
);
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const mailboxListReads = (accountId: string): number =>
|
|
246
|
+
(http?.calls ?? []).filter(
|
|
247
|
+
(call) =>
|
|
248
|
+
call.method === "GET" &&
|
|
249
|
+
call.path.endsWith(`/accounts/${accountId}/mailboxes`),
|
|
250
|
+
).length;
|
|
251
|
+
|
|
252
|
+
describe("answering from the identity the message reached", () => {
|
|
253
|
+
it("opens the reply on the account the message was delivered to", async () => {
|
|
254
|
+
const mounted = await mountAt(`${MESSAGE_PATH}/reply`);
|
|
255
|
+
|
|
256
|
+
assert.equal(
|
|
257
|
+
chosenIdentity(mounted),
|
|
258
|
+
REACHED_ACCOUNT_ID,
|
|
259
|
+
"the reply leaves from the identity the message reached, not the first one configured",
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("keeps the reached identity out of its own Reply All", async () => {
|
|
264
|
+
const mounted = await mountAt(`${MESSAGE_PATH}/reply-all`);
|
|
265
|
+
|
|
266
|
+
assert.deepEqual(
|
|
267
|
+
recipients(mounted, "To"),
|
|
268
|
+
["ada@example.com"],
|
|
269
|
+
"the answer goes back to the sender",
|
|
270
|
+
);
|
|
271
|
+
assert.deepEqual(
|
|
272
|
+
recipients(mounted, "Cc"),
|
|
273
|
+
["carol@example.com"],
|
|
274
|
+
"everyone else on the message is kept, and the reader's own address is not copied back to itself",
|
|
275
|
+
);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
it("refreshes the folder list of the account the message was read in", async () => {
|
|
279
|
+
const mounted = await mountAt(MESSAGE_PATH, { isRead: false });
|
|
280
|
+
|
|
281
|
+
const readsBefore = {
|
|
282
|
+
first: mailboxListReads(FIRST_ACCOUNT_ID),
|
|
283
|
+
reached: mailboxListReads(REACHED_ACCOUNT_ID),
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
await mounted.wait(MARK_READ_DELAY_MS + 100);
|
|
287
|
+
await mounted.flush();
|
|
288
|
+
|
|
289
|
+
assert.equal(
|
|
290
|
+
(http?.to("/messages/flags") ?? []).length,
|
|
291
|
+
1,
|
|
292
|
+
"the message the reader dwelled on was marked read",
|
|
293
|
+
);
|
|
294
|
+
assert.ok(
|
|
295
|
+
mailboxListReads(REACHED_ACCOUNT_ID) > readsBefore.reached,
|
|
296
|
+
"the unread badge of the account the message was read in is refreshed",
|
|
297
|
+
);
|
|
298
|
+
assert.equal(
|
|
299
|
+
mailboxListReads(FIRST_ACCOUNT_ID),
|
|
300
|
+
readsBefore.first,
|
|
301
|
+
"no other account's folder list is disturbed",
|
|
302
|
+
);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every list pane reaches intelligence at every width its reading pane mounts
|
|
3
|
+
* (#817).
|
|
4
|
+
*
|
|
5
|
+
* The shell mounts the reading pane from 1024px and the intelligence rail only
|
|
6
|
+
* from 1280px. The brief and Flagged read the rail's gate as the answer for
|
|
7
|
+
* both, so between the two the toolbar's control rendered greyed out with no
|
|
8
|
+
* drawer behind it, and the authenticity banner's "Why?" was never wired at
|
|
9
|
+
* all — the DKIM explanation was unreachable on the brief at every desktop
|
|
10
|
+
* width. Only the mailbox was ever fixed (#744), which is what left three
|
|
11
|
+
* copies of one rule to drift apart.
|
|
12
|
+
*
|
|
13
|
+
* Mounted in the real `AppShellSlotted`, seeded at 1100px: which panes this
|
|
14
|
+
* width has is the shell's own answer here, not a stub's.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import assert from "node:assert/strict";
|
|
18
|
+
import { afterEach, describe, it } from "node:test";
|
|
19
|
+
import { AppShellSlotted } from "@remit/ui";
|
|
20
|
+
import {
|
|
21
|
+
type AnyRouter,
|
|
22
|
+
createMemoryHistory,
|
|
23
|
+
createRootRoute,
|
|
24
|
+
createRoute,
|
|
25
|
+
createRouter,
|
|
26
|
+
Outlet,
|
|
27
|
+
RouterProvider,
|
|
28
|
+
} from "@tanstack/react-router";
|
|
29
|
+
import { type ComponentType, createElement, type ReactNode } from "react";
|
|
30
|
+
import { ComposeProvider } from "@/components/compose/ComposeProvider";
|
|
31
|
+
import { type OpenThreadPath, useOpenThreadPath } from "@/routing";
|
|
32
|
+
import { createDomHarness, type DomHarness } from "@/test-support/dom";
|
|
33
|
+
import { makeThreadMessage } from "@/test-support/fixtures";
|
|
34
|
+
import { type HttpMock, mockFetch } from "@/test-support/http";
|
|
35
|
+
import { BriefPane } from "./BriefPane";
|
|
36
|
+
import { FlaggedPane } from "./FlaggedPane";
|
|
37
|
+
|
|
38
|
+
/** Below the rail's 1280px gate, above the reading pane's 1024px one. */
|
|
39
|
+
const TWO_PANE_WIDTH = 1100;
|
|
40
|
+
|
|
41
|
+
const THREAD_ID = "thread-1";
|
|
42
|
+
const MESSAGE_ID = "msg-1";
|
|
43
|
+
|
|
44
|
+
const SHOW_INTELLIGENCE = "Show intelligence sidebar";
|
|
45
|
+
const HIDE_INTELLIGENCE = "Hide intelligence sidebar";
|
|
46
|
+
|
|
47
|
+
/** The one row shape that carries a warning: DKIM signed by another domain. */
|
|
48
|
+
const row = makeThreadMessage({
|
|
49
|
+
messageId: MESSAGE_ID,
|
|
50
|
+
threadId: THREAD_ID,
|
|
51
|
+
subject: "Your parcel could not be delivered",
|
|
52
|
+
fromName: "Mondial Relay",
|
|
53
|
+
fromEmail: "delivery.notice@gmail.example",
|
|
54
|
+
hasStars: true,
|
|
55
|
+
star: "yellow",
|
|
56
|
+
authenticity: {
|
|
57
|
+
dkimMismatch: true,
|
|
58
|
+
fromDomain: "mondialrelay.fr",
|
|
59
|
+
dkimDomain: "gmail.example",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/** What the reading pane reads each message's own headers and body from. */
|
|
64
|
+
const describedMessage = {
|
|
65
|
+
messageId: MESSAGE_ID,
|
|
66
|
+
envelope: {
|
|
67
|
+
from: [
|
|
68
|
+
{
|
|
69
|
+
addressId: "addr-1",
|
|
70
|
+
name: "Mondial Relay",
|
|
71
|
+
email: "delivery.notice@gmail.example",
|
|
72
|
+
},
|
|
73
|
+
],
|
|
74
|
+
to: [],
|
|
75
|
+
cc: [],
|
|
76
|
+
bcc: [],
|
|
77
|
+
},
|
|
78
|
+
bodyParts: [],
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
let harness: DomHarness | undefined;
|
|
82
|
+
let http: HttpMock | undefined;
|
|
83
|
+
|
|
84
|
+
afterEach(() => {
|
|
85
|
+
harness?.close();
|
|
86
|
+
harness = undefined;
|
|
87
|
+
http?.restore();
|
|
88
|
+
http = undefined;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// The router reads `self` at construction; the shared jsdom globals stop at
|
|
92
|
+
// `window`.
|
|
93
|
+
(globalThis as { self?: typeof globalThis }).self ??= globalThis;
|
|
94
|
+
|
|
95
|
+
interface PaneUnderTest {
|
|
96
|
+
name: string;
|
|
97
|
+
/** The list's own segment under `/mail`, which is its whole route. */
|
|
98
|
+
segment: string;
|
|
99
|
+
Provider: ComponentType<{
|
|
100
|
+
thread: OpenThreadPath | undefined;
|
|
101
|
+
children: ReactNode;
|
|
102
|
+
}>;
|
|
103
|
+
Reading: ComponentType;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const panes: PaneUnderTest[] = [
|
|
107
|
+
{
|
|
108
|
+
name: "the brief",
|
|
109
|
+
segment: "brief",
|
|
110
|
+
Provider: BriefPane,
|
|
111
|
+
Reading: BriefPane.Reading,
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
name: "Flagged",
|
|
115
|
+
segment: "flagged",
|
|
116
|
+
Provider: FlaggedPane,
|
|
117
|
+
Reading: FlaggedPane.Reading,
|
|
118
|
+
},
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const testRouter = (pane: PaneUnderTest): AnyRouter => {
|
|
122
|
+
const rootRoute = createRootRoute({
|
|
123
|
+
component: () =>
|
|
124
|
+
createElement(ComposeProvider, null, createElement(Outlet)),
|
|
125
|
+
});
|
|
126
|
+
const mailRoute = createRoute({
|
|
127
|
+
getParentRoute: () => rootRoute,
|
|
128
|
+
path: "/mail",
|
|
129
|
+
validateSearch: (search: Record<string, unknown>) => search,
|
|
130
|
+
component: Outlet,
|
|
131
|
+
});
|
|
132
|
+
// Present so `useBrowsedList` has the route its `from` names, the way the
|
|
133
|
+
// generated tree does.
|
|
134
|
+
const mailboxRoute = createRoute({
|
|
135
|
+
getParentRoute: () => mailRoute,
|
|
136
|
+
path: "/$mailboxId",
|
|
137
|
+
component: Outlet,
|
|
138
|
+
});
|
|
139
|
+
const listRoute = createRoute({
|
|
140
|
+
getParentRoute: () => mailRoute,
|
|
141
|
+
path: `/${pane.segment}`,
|
|
142
|
+
component: () =>
|
|
143
|
+
createElement(pane.Provider, {
|
|
144
|
+
thread: useOpenThreadPath(),
|
|
145
|
+
// biome-ignore lint/correctness/noChildrenProp: no JSX in a `.ts` test, and createElement's variadic children do not satisfy a required prop
|
|
146
|
+
children: createElement(Outlet),
|
|
147
|
+
}),
|
|
148
|
+
});
|
|
149
|
+
const threadRoute = createRoute({
|
|
150
|
+
getParentRoute: () => listRoute,
|
|
151
|
+
path: "$threadId",
|
|
152
|
+
component: Outlet,
|
|
153
|
+
});
|
|
154
|
+
// The shell the route mounts, seeded at a width with no room for the rail.
|
|
155
|
+
const messageRoute = createRoute({
|
|
156
|
+
getParentRoute: () => threadRoute,
|
|
157
|
+
path: "$messageId",
|
|
158
|
+
component: () =>
|
|
159
|
+
createElement(AppShellSlotted, {
|
|
160
|
+
initialWidth: TWO_PANE_WIDTH,
|
|
161
|
+
nav: null,
|
|
162
|
+
list: null,
|
|
163
|
+
reading: createElement(pane.Reading),
|
|
164
|
+
}),
|
|
165
|
+
});
|
|
166
|
+
const routeTree = rootRoute.addChildren([
|
|
167
|
+
mailRoute.addChildren([
|
|
168
|
+
mailboxRoute,
|
|
169
|
+
listRoute.addChildren([threadRoute.addChildren([messageRoute])]),
|
|
170
|
+
]),
|
|
171
|
+
]);
|
|
172
|
+
return createRouter({
|
|
173
|
+
routeTree,
|
|
174
|
+
history: createMemoryHistory({
|
|
175
|
+
initialEntries: [`/mail/${pane.segment}/${THREAD_ID}/${MESSAGE_ID}`],
|
|
176
|
+
}),
|
|
177
|
+
}) as unknown as AnyRouter;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const mount = async (pane: PaneUnderTest): Promise<DomHarness> => {
|
|
181
|
+
http = mockFetch((call) => {
|
|
182
|
+
if (call.path.endsWith("/config")) return { accounts: [] };
|
|
183
|
+
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
184
|
+
return { items: [row] };
|
|
185
|
+
}
|
|
186
|
+
if (call.path.includes("/messages/")) return describedMessage;
|
|
187
|
+
if (call.path.includes("/threads")) return { items: [row] };
|
|
188
|
+
return { items: [] };
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
const router = testRouter(pane);
|
|
192
|
+
await router.load();
|
|
193
|
+
const mounted = createDomHarness({ viewportWidth: TWO_PANE_WIDTH });
|
|
194
|
+
harness = mounted;
|
|
195
|
+
mounted.renderApp(createElement(RouterProvider, { router }));
|
|
196
|
+
await settle(mounted);
|
|
197
|
+
return mounted;
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const settle = async (mounted: DomHarness): Promise<void> => {
|
|
201
|
+
await mounted.flush();
|
|
202
|
+
await mounted.wait(20);
|
|
203
|
+
await mounted.flush();
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const drawer = (mounted: DomHarness): HTMLElement | null =>
|
|
207
|
+
mounted.query('[role="dialog"][aria-label="Message details"]');
|
|
208
|
+
|
|
209
|
+
describe("intelligence is reachable wherever the reading pane mounts (#817)", () => {
|
|
210
|
+
for (const pane of panes) {
|
|
211
|
+
it(`${pane.name} offers a live toolbar control below the rail's width`, async () => {
|
|
212
|
+
const mounted = await mount(pane);
|
|
213
|
+
|
|
214
|
+
const toggle = mounted.byLabel(SHOW_INTELLIGENCE) as HTMLButtonElement;
|
|
215
|
+
assert.equal(
|
|
216
|
+
toggle.disabled,
|
|
217
|
+
false,
|
|
218
|
+
"the control was greyed out at a width where the drawer is the surface",
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
mounted.click(toggle);
|
|
222
|
+
await settle(mounted);
|
|
223
|
+
|
|
224
|
+
assert.ok(drawer(mounted), "pressing it opened nothing");
|
|
225
|
+
assert.ok(
|
|
226
|
+
mounted.query(`[aria-label="${HIDE_INTELLIGENCE}"]`),
|
|
227
|
+
"the toolbar still reports the surface as closed",
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it(`${pane.name} reaches the DKIM explanation from the banner`, async () => {
|
|
232
|
+
const mounted = await mount(pane);
|
|
233
|
+
|
|
234
|
+
mounted.click(mounted.byText("button", "Why?"));
|
|
235
|
+
await settle(mounted);
|
|
236
|
+
|
|
237
|
+
assert.ok(
|
|
238
|
+
drawer(mounted),
|
|
239
|
+
"the banner's Why? reached no intelligence surface",
|
|
240
|
+
);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
@@ -344,6 +344,26 @@ describe("buildAuthenticityIntel", () => {
|
|
|
344
344
|
assert.match(result.summary, /only looks like/);
|
|
345
345
|
});
|
|
346
346
|
|
|
347
|
+
test("a display name spelling a foreign address names the domain it cannot come from", () => {
|
|
348
|
+
const result = buildAuthenticityIntel(
|
|
349
|
+
makeThread({
|
|
350
|
+
fromEmail: "aramirez@secresaludguaviare.gov.co",
|
|
351
|
+
fromName: "matthijs@ischen.nl",
|
|
352
|
+
authenticity: {
|
|
353
|
+
fromDomain: "secresaludguaviare.gov.co",
|
|
354
|
+
dkimDomain: "secresaludguaviare.gov.co",
|
|
355
|
+
dkimMismatch: false,
|
|
356
|
+
displayNameCorrespondence: "ForeignAddress",
|
|
357
|
+
offDomainLinkDomains: [],
|
|
358
|
+
},
|
|
359
|
+
} as Partial<RemitImapThreadMessageResponse>),
|
|
360
|
+
0,
|
|
361
|
+
);
|
|
362
|
+
assert.equal(result.verdict, "caution");
|
|
363
|
+
assert.match(result.summary, /"matthijs@ischen\.nl"/);
|
|
364
|
+
assert.match(result.summary, /secresaludguaviare\.gov\.co/);
|
|
365
|
+
});
|
|
366
|
+
|
|
347
367
|
test("stays verified when the comparisons agreed", () => {
|
|
348
368
|
const result = buildAuthenticityIntel(
|
|
349
369
|
makeThread({
|
|
@@ -167,6 +167,10 @@ function describeSenderMismatch(
|
|
|
167
167
|
clauses.push(
|
|
168
168
|
`The name it shows, "${claimedBrand}", only looks like ${auth.fromDomain}.`,
|
|
169
169
|
);
|
|
170
|
+
} else if (correspondence === DisplayNameCorrespondence.ForeignAddress) {
|
|
171
|
+
clauses.push(
|
|
172
|
+
`The name it shows, "${claimedBrand}", is an address ${auth.fromDomain} cannot send from.`,
|
|
173
|
+
);
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
176
|
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { useAppShellLayout } from "@remit/ui";
|
|
2
|
+
import { useCallback } from "react";
|
|
3
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
4
|
+
import { useMailContext } from "@/lib/mail-context";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Which surface intelligence has for the open thread: the rail from 1280 up,
|
|
8
|
+
* the drawer below that, where the reading pane is mounted and the rail has no
|
|
9
|
+
* room. Every list pane asks this the same way — the brief and Flagged read the
|
|
10
|
+
* rail's own gate as the answer for both tiers and offered a disabled control
|
|
11
|
+
* with nothing behind it between 1024 and 1280 (#817).
|
|
12
|
+
*/
|
|
13
|
+
export interface IntelligenceSurface {
|
|
14
|
+
/** There is a thread to say anything about, so the toolbar's control acts. */
|
|
15
|
+
canToggle: boolean;
|
|
16
|
+
/** What the toolbar reports: the rail above 1280, the drawer below it. */
|
|
17
|
+
isShowing: boolean;
|
|
18
|
+
/** The toolbar's control, over whichever surface this width has. */
|
|
19
|
+
toggle: () => void;
|
|
20
|
+
/** The banner's "Why?" — an open, never a close. */
|
|
21
|
+
open: () => void;
|
|
22
|
+
drawerOpen: boolean;
|
|
23
|
+
closeDrawer: () => void;
|
|
24
|
+
/** Whether this width has room for the rail, so the rail is the surface. */
|
|
25
|
+
railFits: boolean;
|
|
26
|
+
/** Raise the rail if it fits and is down. The DKIM auto-open's way in. */
|
|
27
|
+
openRail: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const useIntelligenceSurface = (
|
|
31
|
+
openThreadId: string | undefined,
|
|
32
|
+
): IntelligenceSurface => {
|
|
33
|
+
const { intelligenceOpen, onToggleIntelligence } = useMailContext();
|
|
34
|
+
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
35
|
+
const threadId = openThreadId ?? null;
|
|
36
|
+
const drawer = useIntelligenceDrawer(threadId);
|
|
37
|
+
// The rail wins wherever it fits, so the drawer stays down there even if it
|
|
38
|
+
// was asked for at a narrower width the reader has since grown out of.
|
|
39
|
+
const drawerOpen = !railFits && drawer.isOpen;
|
|
40
|
+
|
|
41
|
+
const { open: openDrawer, toggle: toggleDrawer } = drawer;
|
|
42
|
+
const toggle = useCallback(() => {
|
|
43
|
+
if (railFits) {
|
|
44
|
+
onToggleIntelligence();
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
toggleDrawer();
|
|
48
|
+
}, [railFits, onToggleIntelligence, toggleDrawer]);
|
|
49
|
+
const openRail = useCallback(() => {
|
|
50
|
+
if (!railFits || intelligenceOpen) return;
|
|
51
|
+
onToggleIntelligence();
|
|
52
|
+
}, [railFits, intelligenceOpen, onToggleIntelligence]);
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
canToggle: threadId !== null,
|
|
56
|
+
isShowing: threadId !== null && (railFits ? intelligenceOpen : drawerOpen),
|
|
57
|
+
toggle,
|
|
58
|
+
open: railFits ? onToggleIntelligence : openDrawer,
|
|
59
|
+
drawerOpen,
|
|
60
|
+
closeDrawer: drawer.close,
|
|
61
|
+
railFits,
|
|
62
|
+
openRail,
|
|
63
|
+
};
|
|
64
|
+
};
|