@remit/web-client 0.0.174 → 0.0.176
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/AddressField.tsx +2 -1
- package/src/components/mail/BriefPane.tsx +7 -6
- package/src/components/mail/FlaggedPane.tsx +7 -6
- package/src/components/mail/MailboxPane.tsx +36 -53
- package/src/components/mail/intelligence-drawer.stories.tsx +59 -43
- package/src/hooks/intelligence-drawer.render.test.ts +99 -0
- package/src/hooks/useIntelligenceDrawer.ts +54 -0
- package/src/lib/recipient-suggestions.test.ts +109 -0
- package/src/lib/recipient-suggestions.ts +15 -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.176",
|
|
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": {
|
|
@@ -3,6 +3,7 @@ import { type AddressEntry, ComposeAddressField } from "@remit/ui";
|
|
|
3
3
|
import { useQuery } from "@tanstack/react-query";
|
|
4
4
|
import { useMemo, useState } from "react";
|
|
5
5
|
import { useDebouncedValue } from "@/hooks/useDebouncedValue";
|
|
6
|
+
import { offerableAsRecipient } from "@/lib/recipient-suggestions";
|
|
6
7
|
|
|
7
8
|
export type { AddressEntry };
|
|
8
9
|
|
|
@@ -32,7 +33,7 @@ export const AddressField = ({
|
|
|
32
33
|
|
|
33
34
|
const suggestions = useMemo<AddressEntry[]>(
|
|
34
35
|
() =>
|
|
35
|
-
(data?.items ?? []).map((item) => ({
|
|
36
|
+
(data?.items ?? []).filter(offerableAsRecipient).map((item) => ({
|
|
36
37
|
email: item.normalizedEmail,
|
|
37
38
|
displayName: item.displayName,
|
|
38
39
|
})),
|
|
@@ -31,6 +31,7 @@ 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
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
34
35
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
35
36
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
36
37
|
import { useThreadRow } from "@/hooks/useThreadRow";
|
|
@@ -408,7 +409,7 @@ function BriefPhone() {
|
|
|
408
409
|
previousThread,
|
|
409
410
|
handleDeselectIfRemoved,
|
|
410
411
|
} = useBriefPane();
|
|
411
|
-
const
|
|
412
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
412
413
|
|
|
413
414
|
if (conversation) {
|
|
414
415
|
return (
|
|
@@ -420,21 +421,21 @@ function BriefPhone() {
|
|
|
420
421
|
selectedMessageId={conversation.messageId}
|
|
421
422
|
authenticity={conversation.authenticity}
|
|
422
423
|
onBack={onCloseThread}
|
|
423
|
-
onOpenIntelligence={
|
|
424
|
+
onOpenIntelligence={drawer.toggle}
|
|
424
425
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
425
426
|
onSwipePrevious={
|
|
426
427
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
427
428
|
}
|
|
428
|
-
mobileIntelligenceOpen={
|
|
429
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
429
430
|
/>
|
|
430
431
|
<Drawer
|
|
431
|
-
isOpen={
|
|
432
|
-
onClose={
|
|
432
|
+
isOpen={drawer.isOpen}
|
|
433
|
+
onClose={drawer.close}
|
|
433
434
|
ariaLabel="Message details"
|
|
434
435
|
side="right"
|
|
435
436
|
>
|
|
436
437
|
<IntelligencePane
|
|
437
|
-
onClose={
|
|
438
|
+
onClose={drawer.close}
|
|
438
439
|
thread={selectedThread}
|
|
439
440
|
mailboxId={selectedThread?.mailboxId}
|
|
440
441
|
accountId={selectedThread?.accountId}
|
|
@@ -41,6 +41,7 @@ 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
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
44
45
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
45
46
|
import { useStarredThreads } from "@/hooks/useStarredThreads";
|
|
46
47
|
import { type ThreadActions, useThreadActions } from "@/hooks/useThreadActions";
|
|
@@ -409,7 +410,7 @@ function FlaggedPhone() {
|
|
|
409
410
|
previousThread,
|
|
410
411
|
handleDeselectIfRemoved,
|
|
411
412
|
} = useFlaggedPane();
|
|
412
|
-
const
|
|
413
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
413
414
|
|
|
414
415
|
if (conversation) {
|
|
415
416
|
return (
|
|
@@ -421,21 +422,21 @@ function FlaggedPhone() {
|
|
|
421
422
|
selectedMessageId={conversation.messageId}
|
|
422
423
|
authenticity={conversation.authenticity}
|
|
423
424
|
onBack={onCloseThread}
|
|
424
|
-
onOpenIntelligence={
|
|
425
|
+
onOpenIntelligence={drawer.toggle}
|
|
425
426
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
426
427
|
onSwipePrevious={
|
|
427
428
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
428
429
|
}
|
|
429
|
-
mobileIntelligenceOpen={
|
|
430
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
430
431
|
/>
|
|
431
432
|
<Drawer
|
|
432
|
-
isOpen={
|
|
433
|
-
onClose={
|
|
433
|
+
isOpen={drawer.isOpen}
|
|
434
|
+
onClose={drawer.close}
|
|
434
435
|
ariaLabel="Message details"
|
|
435
436
|
side="right"
|
|
436
437
|
>
|
|
437
438
|
<IntelligencePane
|
|
438
|
-
onClose={
|
|
439
|
+
onClose={drawer.close}
|
|
439
440
|
thread={selectedThread}
|
|
440
441
|
mailboxId={selectedThread?.mailboxId}
|
|
441
442
|
accountId={selectedThread?.accountId}
|
|
@@ -72,6 +72,7 @@ import {
|
|
|
72
72
|
} from "@/hooks/useDeleteMessages";
|
|
73
73
|
import type { EscalationSearchQuery } from "@/hooks/useEscalatedActions";
|
|
74
74
|
import { useIntelligenceData } from "@/hooks/useIntelligenceData";
|
|
75
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
75
76
|
import { useLayoutTier } from "@/hooks/useLayoutTier";
|
|
76
77
|
import { useMailboxAccount } from "@/hooks/useMailboxAccount";
|
|
77
78
|
import { useToggleReadFor } from "@/hooks/useMarkAsRead";
|
|
@@ -507,23 +508,6 @@ function MailboxPaneProvider({
|
|
|
507
508
|
const focusedThread =
|
|
508
509
|
threads.find((t) => t.messageId === triageFocusedId) ?? selectedThread;
|
|
509
510
|
|
|
510
|
-
// Auto-open intelligence pane on DKIM mismatch.
|
|
511
|
-
const autoOpenedForRef = useRef<string | null>(null);
|
|
512
|
-
useEffect(() => {
|
|
513
|
-
const id = selectedThread?.messageId ?? null;
|
|
514
|
-
if (!id) return;
|
|
515
|
-
if (autoOpenedForRef.current === id) return;
|
|
516
|
-
if (selectedThread?.authenticity?.dkimMismatch) {
|
|
517
|
-
autoOpenedForRef.current = id;
|
|
518
|
-
if (!intelligenceOpen) onToggleIntelligence();
|
|
519
|
-
}
|
|
520
|
-
}, [
|
|
521
|
-
selectedThread?.messageId,
|
|
522
|
-
selectedThread?.authenticity?.dkimMismatch,
|
|
523
|
-
intelligenceOpen,
|
|
524
|
-
onToggleIntelligence,
|
|
525
|
-
]);
|
|
526
|
-
|
|
527
511
|
// The mailbox's own unseen total. A count over the loaded pages undercounts
|
|
528
512
|
// every mailbox larger than one page and creeps upward as the user scrolls,
|
|
529
513
|
// so there is no fallback: until the mailbox resolves there is no number.
|
|
@@ -1085,41 +1069,41 @@ function MailboxReading() {
|
|
|
1085
1069
|
const railFits = useAppShellLayout()?.showIntelligencePane ?? false;
|
|
1086
1070
|
const hasThread = Boolean(conversation);
|
|
1087
1071
|
|
|
1088
|
-
// The
|
|
1089
|
-
//
|
|
1090
|
-
//
|
|
1091
|
-
//
|
|
1092
|
-
//
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
!
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
);
|
|
1111
|
-
|
|
1112
|
-
const
|
|
1113
|
-
|
|
1114
|
-
: openIntelligenceDrawer;
|
|
1072
|
+
// The rail opens itself on a DKIM mismatch. It lives here, behind the rail's
|
|
1073
|
+
// own width gate, because raising it writes `#intelligence` into the address
|
|
1074
|
+
// — and a panel the address names with no renderer behind it is a panel that
|
|
1075
|
+
// opens nothing (`docs/architecture/url-state.md`, R6). Below this width the
|
|
1076
|
+
// banner is the announcement and its "Why?" is the way in.
|
|
1077
|
+
const autoOpenedForRef = useRef<string | null>(null);
|
|
1078
|
+
useEffect(() => {
|
|
1079
|
+
if (!railFits) return;
|
|
1080
|
+
const id = selectedThread?.messageId ?? null;
|
|
1081
|
+
if (!id) return;
|
|
1082
|
+
if (autoOpenedForRef.current === id) return;
|
|
1083
|
+
if (!selectedThread?.authenticity?.dkimMismatch) return;
|
|
1084
|
+
autoOpenedForRef.current = id;
|
|
1085
|
+
if (!intelligenceOpen) onToggleIntelligence();
|
|
1086
|
+
}, [
|
|
1087
|
+
railFits,
|
|
1088
|
+
selectedThread?.messageId,
|
|
1089
|
+
selectedThread?.authenticity?.dkimMismatch,
|
|
1090
|
+
intelligenceOpen,
|
|
1091
|
+
onToggleIntelligence,
|
|
1092
|
+
]);
|
|
1093
|
+
|
|
1094
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1095
|
+
const drawerOpen = !railFits && drawer.isOpen;
|
|
1096
|
+
const closeIntelligenceDrawer = drawer.close;
|
|
1097
|
+
const openIntelligence = railFits ? onToggleIntelligence : drawer.open;
|
|
1115
1098
|
// The toolbar's control, which toggles whichever surface this width has.
|
|
1099
|
+
const { toggle: toggleDrawer } = drawer;
|
|
1116
1100
|
const toggleIntelligence = useCallback(() => {
|
|
1117
1101
|
if (railFits) {
|
|
1118
1102
|
onToggleIntelligence();
|
|
1119
1103
|
return;
|
|
1120
1104
|
}
|
|
1121
|
-
|
|
1122
|
-
}, [railFits, onToggleIntelligence,
|
|
1105
|
+
toggleDrawer();
|
|
1106
|
+
}, [railFits, onToggleIntelligence, toggleDrawer]);
|
|
1123
1107
|
const intelligenceShowing =
|
|
1124
1108
|
hasThread && (railFits ? intelligenceOpen : drawerOpen);
|
|
1125
1109
|
|
|
@@ -1219,13 +1203,12 @@ function MailboxPhone() {
|
|
|
1219
1203
|
selectedThread,
|
|
1220
1204
|
conversation,
|
|
1221
1205
|
onOpenThread,
|
|
1222
|
-
intelligenceOpen,
|
|
1223
|
-
onToggleIntelligence,
|
|
1224
1206
|
onBack,
|
|
1225
1207
|
nextThread,
|
|
1226
1208
|
previousThread,
|
|
1227
1209
|
handleDeselectIfRemoved,
|
|
1228
1210
|
} = useMailboxPane();
|
|
1211
|
+
const drawer = useIntelligenceDrawer(conversation?.threadId ?? null);
|
|
1229
1212
|
|
|
1230
1213
|
if (conversation) {
|
|
1231
1214
|
return (
|
|
@@ -1237,21 +1220,21 @@ function MailboxPhone() {
|
|
|
1237
1220
|
selectedMessageId={conversation.messageId}
|
|
1238
1221
|
authenticity={conversation.authenticity}
|
|
1239
1222
|
onBack={onBack}
|
|
1240
|
-
onOpenIntelligence={
|
|
1223
|
+
onOpenIntelligence={drawer.toggle}
|
|
1241
1224
|
onSwipeNext={nextThread ? () => onOpenThread(nextThread) : undefined}
|
|
1242
1225
|
onSwipePrevious={
|
|
1243
1226
|
previousThread ? () => onOpenThread(previousThread) : undefined
|
|
1244
1227
|
}
|
|
1245
|
-
mobileIntelligenceOpen={
|
|
1228
|
+
mobileIntelligenceOpen={drawer.isOpen}
|
|
1246
1229
|
/>
|
|
1247
1230
|
<Drawer
|
|
1248
|
-
isOpen={
|
|
1249
|
-
onClose={
|
|
1231
|
+
isOpen={drawer.isOpen}
|
|
1232
|
+
onClose={drawer.close}
|
|
1250
1233
|
ariaLabel="Message details"
|
|
1251
1234
|
side="right"
|
|
1252
1235
|
>
|
|
1253
1236
|
<IntelligencePane
|
|
1254
|
-
onClose={
|
|
1237
|
+
onClose={drawer.close}
|
|
1255
1238
|
thread={selectedThread}
|
|
1256
1239
|
mailboxId={mailboxId}
|
|
1257
1240
|
accountId={mailboxAccountId}
|
|
@@ -4,20 +4,20 @@ import { ChevronLeft, Info } from "lucide-react";
|
|
|
4
4
|
import { useState } from "react";
|
|
5
5
|
import { expect, userEvent, within } from "storybook/test";
|
|
6
6
|
import { Drawer } from "@/components/layout/Drawer";
|
|
7
|
-
import {
|
|
8
|
-
import { parseOpenPanels, retainOpenPanelsAtTier } from "@/routing";
|
|
7
|
+
import { useIntelligenceDrawer } from "@/hooks/useIntelligenceDrawer";
|
|
9
8
|
|
|
10
9
|
/**
|
|
11
|
-
* The intelligence drawer below the desktop tier (#777).
|
|
10
|
+
* The intelligence drawer below the desktop tier (#777, #778).
|
|
12
11
|
*
|
|
13
12
|
* There is no fourth pane here: the rail is a full-screen drawer over the open
|
|
14
|
-
* message, so it belongs to the thread it was opened for
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* message, so it belongs to the thread it was opened for and to nothing else.
|
|
14
|
+
* It is modal, so it opens only when the reader asks — a DKIM mismatch shows
|
|
15
|
+
* the banner and leaves the message alone. A drawer that opened itself would
|
|
16
|
+
* put a scrim over the message, and the reader's next tap would land on the
|
|
17
|
+
* scrim rather than on Back.
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
19
|
+
* The rule is the app's own: `useIntelligenceDrawer` is the hook the phone and
|
|
20
|
+
* mid-width panes both run on.
|
|
21
21
|
*/
|
|
22
22
|
const meta: Meta = {
|
|
23
23
|
title: "Flows/Reading/Intelligence Drawer",
|
|
@@ -30,8 +30,6 @@ type Story = StoryObj;
|
|
|
30
30
|
const PHONE_WIDTH = 390;
|
|
31
31
|
const PHONE_HEIGHT = 720;
|
|
32
32
|
|
|
33
|
-
const retainOnPhone = retainOpenPanelsAtTier(false);
|
|
34
|
-
|
|
35
33
|
const intelligence: IntelligenceData = {
|
|
36
34
|
sender: {
|
|
37
35
|
name: "Mondial Relay",
|
|
@@ -77,23 +75,14 @@ const messages: readonly Message[] = [
|
|
|
77
75
|
];
|
|
78
76
|
|
|
79
77
|
/**
|
|
80
|
-
* The phone reader
|
|
81
|
-
*
|
|
78
|
+
* The phone reader. What is open is the thread on screen and, beside it, the
|
|
79
|
+
* drawer the reader asked for — held by `useIntelligenceDrawer` against that
|
|
80
|
+
* thread, so opening another one takes the drawer down with it.
|
|
82
81
|
*/
|
|
83
|
-
const PhoneReader = (
|
|
84
|
-
const [hash, setHash] = useState(initialHash);
|
|
82
|
+
const PhoneReader = () => {
|
|
85
83
|
const [openId, setOpenId] = useState<string | undefined>(messages[0]?.id);
|
|
86
84
|
const open = messages.find((message) => message.id === openId);
|
|
87
|
-
const
|
|
88
|
-
panels: parseOpenPanels(hash),
|
|
89
|
-
prefersOpen: true,
|
|
90
|
-
isDesktop: false,
|
|
91
|
-
hasThread: open !== undefined,
|
|
92
|
-
});
|
|
93
|
-
const navigate = (nextId: string | undefined) => {
|
|
94
|
-
setHash(retainOnPhone(hash));
|
|
95
|
-
setOpenId(nextId);
|
|
96
|
-
};
|
|
85
|
+
const drawer = useIntelligenceDrawer(open?.id ?? null);
|
|
97
86
|
|
|
98
87
|
return (
|
|
99
88
|
<div className="flex flex-col gap-2">
|
|
@@ -113,7 +102,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
113
102
|
<button
|
|
114
103
|
type="button"
|
|
115
104
|
aria-label="Back"
|
|
116
|
-
onClick={() =>
|
|
105
|
+
onClick={() => setOpenId(undefined)}
|
|
117
106
|
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
118
107
|
>
|
|
119
108
|
<ChevronLeft className="size-5" />
|
|
@@ -124,7 +113,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
124
113
|
<button
|
|
125
114
|
type="button"
|
|
126
115
|
aria-label="Message details"
|
|
127
|
-
onClick={
|
|
116
|
+
onClick={drawer.toggle}
|
|
128
117
|
className="inline-flex size-11 items-center justify-center rounded-md text-fg hover:bg-surface-raised"
|
|
129
118
|
>
|
|
130
119
|
<Info className="size-5" />
|
|
@@ -141,7 +130,7 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
141
130
|
<li key={message.id}>
|
|
142
131
|
<button
|
|
143
132
|
type="button"
|
|
144
|
-
onClick={() =>
|
|
133
|
+
onClick={() => setOpenId(message.id)}
|
|
145
134
|
className="flex w-full flex-col items-start gap-0.5 border-b border-line px-4 py-3 text-left hover:bg-surface-raised"
|
|
146
135
|
>
|
|
147
136
|
<span className="text-sm font-semibold text-fg">
|
|
@@ -156,8 +145,8 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
156
145
|
</ul>
|
|
157
146
|
)}
|
|
158
147
|
<Drawer
|
|
159
|
-
isOpen={
|
|
160
|
-
onClose={
|
|
148
|
+
isOpen={drawer.isOpen}
|
|
149
|
+
onClose={drawer.close}
|
|
161
150
|
ariaLabel="Message details"
|
|
162
151
|
side="right"
|
|
163
152
|
>
|
|
@@ -165,34 +154,58 @@ const PhoneReader = ({ initialHash = "" }: { initialHash?: string }) => {
|
|
|
165
154
|
</Drawer>
|
|
166
155
|
</div>
|
|
167
156
|
<p className="font-mono text-2xs text-fg-subtle">
|
|
168
|
-
{`/mail/inbox${open ? `/${open.id}` : ""}
|
|
157
|
+
{`/mail/inbox${open ? `/${open.id}` : ""}`}
|
|
169
158
|
</p>
|
|
170
159
|
</div>
|
|
171
160
|
);
|
|
172
161
|
};
|
|
173
162
|
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
163
|
+
/** The reader's own press on the details control. */
|
|
164
|
+
const openDetails = async (canvasElement: HTMLElement): Promise<void> => {
|
|
165
|
+
await userEvent.click(
|
|
166
|
+
within(canvasElement).getByLabelText("Message details"),
|
|
167
|
+
);
|
|
177
168
|
};
|
|
178
169
|
|
|
179
170
|
/**
|
|
180
|
-
* The
|
|
181
|
-
*
|
|
171
|
+
* The warned message as it opens: the banner's own wording, and nothing over
|
|
172
|
+
* it. A mismatch is what the panel explains, never a reason to cover the
|
|
173
|
+
* message with it — a scrim there takes the reader's next tap, Back included.
|
|
182
174
|
*/
|
|
175
|
+
export const Closed: Story = {
|
|
176
|
+
render: () => <PhoneReader />,
|
|
177
|
+
play: async ({ canvasElement }) => {
|
|
178
|
+
const canvas = within(canvasElement);
|
|
179
|
+
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
180
|
+
await expect(canvas.getByLabelText("Back")).toBeVisible();
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
/** The drawer over the message it was opened for. */
|
|
183
185
|
export const OpenOverThread: Story = {
|
|
184
|
-
render: () => <PhoneReader
|
|
186
|
+
render: () => <PhoneReader />,
|
|
187
|
+
play: async ({ canvasElement }) => {
|
|
188
|
+
await openDetails(canvasElement);
|
|
189
|
+
await expect(within(canvasElement).getByRole("dialog")).toBeVisible();
|
|
190
|
+
},
|
|
185
191
|
};
|
|
186
192
|
|
|
187
193
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
* first. Press Back with the drawer up to walk it.
|
|
194
|
+
* Dismissed, and gone for the next message too: the drawer is held against the
|
|
195
|
+
* thread it was opened for, so nothing carries it forward.
|
|
191
196
|
*/
|
|
192
|
-
export const
|
|
193
|
-
render: () => <PhoneReader
|
|
197
|
+
export const DismissedAndNotCarried: Story = {
|
|
198
|
+
render: () => <PhoneReader />,
|
|
194
199
|
play: async ({ canvasElement }) => {
|
|
195
200
|
const canvas = within(canvasElement);
|
|
201
|
+
await openDetails(canvasElement);
|
|
202
|
+
await expect(canvas.getByRole("dialog")).toBeVisible();
|
|
203
|
+
|
|
204
|
+
// The scrim carries the same name as the header's close button and comes
|
|
205
|
+
// first in the drawer, so the visible control is the second of the two.
|
|
206
|
+
await userEvent.click(canvas.getAllByLabelText("Close menu")[1]);
|
|
207
|
+
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
208
|
+
|
|
196
209
|
await userEvent.click(canvas.getByLabelText("Back"));
|
|
197
210
|
await userEvent.click(canvas.getByText("Standup moved to 10:15"));
|
|
198
211
|
await expect(canvas.queryByRole("dialog")).toBeNull();
|
|
@@ -204,5 +217,8 @@ export const DroppedByNavigation: Story = {
|
|
|
204
217
|
export const OpenOverThreadDark: Story = {
|
|
205
218
|
name: "Open Over Thread (dark)",
|
|
206
219
|
parameters: { theme: "dark" },
|
|
207
|
-
render: () => <PhoneReader
|
|
220
|
+
render: () => <PhoneReader />,
|
|
221
|
+
play: async ({ canvasElement }) => {
|
|
222
|
+
await openDetails(canvasElement);
|
|
223
|
+
},
|
|
208
224
|
};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The intelligence drawer is modal, so what matters as much as opening it is
|
|
3
|
+
* that nothing else can. It belongs to the thread it was opened for: leaving
|
|
4
|
+
* that thread puts it away, and coming back to the same message opens it clear
|
|
5
|
+
* rather than under the scrim it was last left with (#778).
|
|
6
|
+
*
|
|
7
|
+
* The reachable case is a phone's system Back, which is a navigation like any
|
|
8
|
+
* other — the thread goes, and the drawer has to go with it for good.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
13
|
+
import { createElement } from "react";
|
|
14
|
+
import { createDomHarness, type DomHarness } from "../test-support/dom";
|
|
15
|
+
import { useIntelligenceDrawer } from "./useIntelligenceDrawer";
|
|
16
|
+
|
|
17
|
+
let dom: DomHarness;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
dom = createDomHarness();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
afterEach(() => {
|
|
24
|
+
dom.close();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/** Reports what the drawer says, and offers the controls a reader has. */
|
|
28
|
+
const Probe = ({ threadId }: { threadId: string | null }) => {
|
|
29
|
+
const drawer = useIntelligenceDrawer(threadId);
|
|
30
|
+
return createElement(
|
|
31
|
+
"div",
|
|
32
|
+
null,
|
|
33
|
+
createElement(
|
|
34
|
+
"span",
|
|
35
|
+
{ "data-state": "" },
|
|
36
|
+
drawer.isOpen ? "open" : "shut",
|
|
37
|
+
),
|
|
38
|
+
createElement("button", {
|
|
39
|
+
type: "button",
|
|
40
|
+
"data-open": "",
|
|
41
|
+
onClick: drawer.open,
|
|
42
|
+
}),
|
|
43
|
+
createElement("button", {
|
|
44
|
+
type: "button",
|
|
45
|
+
"data-toggle": "",
|
|
46
|
+
onClick: drawer.toggle,
|
|
47
|
+
}),
|
|
48
|
+
);
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const state = (): string | undefined =>
|
|
52
|
+
dom.query("[data-state]")?.textContent ?? undefined;
|
|
53
|
+
const press = (handle: string): void => {
|
|
54
|
+
const button = dom.query(`[${handle}]`);
|
|
55
|
+
if (!button) throw new Error(`no ${handle} control`);
|
|
56
|
+
dom.click(button);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
describe("the intelligence drawer", () => {
|
|
60
|
+
it("opens only when it is asked to", () => {
|
|
61
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
62
|
+
assert.equal(state(), "shut");
|
|
63
|
+
|
|
64
|
+
press("data-open");
|
|
65
|
+
assert.equal(state(), "open");
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("does not come back with the message it was opened over", () => {
|
|
69
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
70
|
+
press("data-open");
|
|
71
|
+
assert.equal(state(), "open");
|
|
72
|
+
|
|
73
|
+
// System Back: the thread closes under it.
|
|
74
|
+
dom.render(createElement(Probe, { threadId: null }));
|
|
75
|
+
assert.equal(state(), "shut");
|
|
76
|
+
|
|
77
|
+
// And the same message again, which is where the scrim used to be waiting.
|
|
78
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
79
|
+
assert.equal(state(), "shut");
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("stays behind when the reader moves to another thread", () => {
|
|
83
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
84
|
+
press("data-open");
|
|
85
|
+
assert.equal(state(), "open");
|
|
86
|
+
|
|
87
|
+
dom.render(createElement(Probe, { threadId: "thread-b" }));
|
|
88
|
+
assert.equal(state(), "shut");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("toggles shut again from the toolbar's control", () => {
|
|
92
|
+
dom.render(createElement(Probe, { threadId: "thread-a" }));
|
|
93
|
+
press("data-toggle");
|
|
94
|
+
assert.equal(state(), "open");
|
|
95
|
+
|
|
96
|
+
press("data-toggle");
|
|
97
|
+
assert.equal(state(), "shut");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { useCallback, useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The intelligence drawer's own state, scoped to the thread it was opened for.
|
|
5
|
+
*
|
|
6
|
+
* The drawer is modal, so it opens only when it is asked for. `intelligenceOpen`
|
|
7
|
+
* is the rail's persisted preference and the DKIM auto-open sets it on every
|
|
8
|
+
* tier, so driving the drawer from it throws a scrim over a message the moment
|
|
9
|
+
* one is selected — and the reader's next tap lands on that scrim instead of the
|
|
10
|
+
* control they aimed at, Back to messages included. Naming the thread is also
|
|
11
|
+
* what closes it again when they move on: a bare flag would still be set when
|
|
12
|
+
* they came back.
|
|
13
|
+
*/
|
|
14
|
+
export interface IntelligenceDrawer {
|
|
15
|
+
/** Up only while the thread it was opened for is the one on screen. */
|
|
16
|
+
isOpen: boolean;
|
|
17
|
+
/** The banner's "Why?" — always an open, never a close. */
|
|
18
|
+
open: () => void;
|
|
19
|
+
close: () => void;
|
|
20
|
+
/** The toolbar's control. */
|
|
21
|
+
toggle: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function useIntelligenceDrawer(
|
|
25
|
+
openThreadId: string | null,
|
|
26
|
+
): IntelligenceDrawer {
|
|
27
|
+
const [drawerThreadId, setDrawerThreadId] = useState<string | null>(null);
|
|
28
|
+
// Leaving the thread puts the drawer away for good, rather than leaving it
|
|
29
|
+
// armed for the reader's return. Held state alone would reopen it: press
|
|
30
|
+
// system Back and come to the same message again and the scrim would be
|
|
31
|
+
// waiting, which is the defect this hook exists to end (#778).
|
|
32
|
+
//
|
|
33
|
+
// Adjusted during render, not in an effect: React re-runs the render before
|
|
34
|
+
// committing, so nothing paints with the stale thread. An effect would show
|
|
35
|
+
// one frame of an open drawer over the message first.
|
|
36
|
+
if (drawerThreadId !== null && drawerThreadId !== openThreadId) {
|
|
37
|
+
setDrawerThreadId(null);
|
|
38
|
+
}
|
|
39
|
+
// Derived rather than stored: moving to another thread closes it with no
|
|
40
|
+
// effect to run.
|
|
41
|
+
const isOpen = openThreadId !== null && drawerThreadId === openThreadId;
|
|
42
|
+
|
|
43
|
+
const close = useCallback(() => setDrawerThreadId(null), []);
|
|
44
|
+
const open = useCallback(
|
|
45
|
+
() => setDrawerThreadId(openThreadId),
|
|
46
|
+
[openThreadId],
|
|
47
|
+
);
|
|
48
|
+
const toggle = useCallback(
|
|
49
|
+
() => setDrawerThreadId(isOpen ? null : openThreadId),
|
|
50
|
+
[isOpen, openThreadId],
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
return { isOpen, open, close, toggle };
|
|
54
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client";
|
|
4
|
+
import { offerableAsRecipient } from "./recipient-suggestions";
|
|
5
|
+
|
|
6
|
+
const setAt = 1;
|
|
7
|
+
|
|
8
|
+
const address = (
|
|
9
|
+
over: Partial<RemitImapAddressResponse> = {},
|
|
10
|
+
): RemitImapAddressResponse =>
|
|
11
|
+
({
|
|
12
|
+
normalizedEmail: "sales@pharma.example",
|
|
13
|
+
outboundCount: 0,
|
|
14
|
+
replyCount: 0,
|
|
15
|
+
flags: {},
|
|
16
|
+
...over,
|
|
17
|
+
}) as RemitImapAddressResponse;
|
|
18
|
+
|
|
19
|
+
describe("who the compose picker may offer", () => {
|
|
20
|
+
it("refuses a sender met only on mail in Junk", () => {
|
|
21
|
+
assert.equal(
|
|
22
|
+
offerableAsRecipient(
|
|
23
|
+
address({ flags: { junkOnly: { value: true, setAt } } }),
|
|
24
|
+
),
|
|
25
|
+
false,
|
|
26
|
+
);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("refuses a sender the account blocked", () => {
|
|
30
|
+
assert.equal(
|
|
31
|
+
offerableAsRecipient(
|
|
32
|
+
address({ flags: { blocked: { value: true, setAt } } }),
|
|
33
|
+
),
|
|
34
|
+
false,
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("refuses a blocked sender the search still has to return", () => {
|
|
39
|
+
assert.equal(
|
|
40
|
+
offerableAsRecipient(
|
|
41
|
+
address({
|
|
42
|
+
flags: {
|
|
43
|
+
junkOnly: { value: true, setAt },
|
|
44
|
+
blocked: { value: true, setAt },
|
|
45
|
+
},
|
|
46
|
+
}),
|
|
47
|
+
),
|
|
48
|
+
false,
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("offers a muted sender, which is about notice and not about mail", () => {
|
|
53
|
+
assert.equal(
|
|
54
|
+
offerableAsRecipient(
|
|
55
|
+
address({ flags: { muted: { value: true, setAt } } }),
|
|
56
|
+
),
|
|
57
|
+
true,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("offers a withheld sender the account has since sent mail to", () => {
|
|
62
|
+
assert.equal(
|
|
63
|
+
offerableAsRecipient(
|
|
64
|
+
address({
|
|
65
|
+
outboundCount: 1,
|
|
66
|
+
flags: { junkOnly: { value: true, setAt } },
|
|
67
|
+
}),
|
|
68
|
+
),
|
|
69
|
+
true,
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("offers a withheld sender the account has since replied to", () => {
|
|
74
|
+
assert.equal(
|
|
75
|
+
offerableAsRecipient(
|
|
76
|
+
address({ replyCount: 1, flags: { junkOnly: { value: true, setAt } } }),
|
|
77
|
+
),
|
|
78
|
+
true,
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("offers a withheld sender the account has since named a VIP", () => {
|
|
83
|
+
assert.equal(
|
|
84
|
+
offerableAsRecipient(
|
|
85
|
+
address({
|
|
86
|
+
flags: {
|
|
87
|
+
junkOnly: { value: true, setAt },
|
|
88
|
+
vip: { value: true, setAt },
|
|
89
|
+
},
|
|
90
|
+
}),
|
|
91
|
+
),
|
|
92
|
+
true,
|
|
93
|
+
);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("offers a withheld sender the account has since trusted", () => {
|
|
97
|
+
assert.equal(
|
|
98
|
+
offerableAsRecipient(
|
|
99
|
+
address({
|
|
100
|
+
flags: {
|
|
101
|
+
junkOnly: { value: true, setAt },
|
|
102
|
+
trusted: { value: true, setAt },
|
|
103
|
+
},
|
|
104
|
+
}),
|
|
105
|
+
),
|
|
106
|
+
true,
|
|
107
|
+
);
|
|
108
|
+
});
|
|
109
|
+
});
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { RemitImapAddressResponse } from "@remit/api-http-client";
|
|
2
|
+
|
|
3
|
+
const accountHasCorresponded = (address: RemitImapAddressResponse): boolean =>
|
|
4
|
+
address.outboundCount > 0 ||
|
|
5
|
+
address.replyCount > 0 ||
|
|
6
|
+
address.flags?.vip?.value === true ||
|
|
7
|
+
address.flags?.trusted?.value === true;
|
|
8
|
+
|
|
9
|
+
export const offerableAsRecipient = (
|
|
10
|
+
address: RemitImapAddressResponse,
|
|
11
|
+
): boolean => {
|
|
12
|
+
if (address.flags?.blocked?.value === true) return false;
|
|
13
|
+
if (address.flags?.junkOnly?.value !== true) return true;
|
|
14
|
+
return accountHasCorresponded(address);
|
|
15
|
+
};
|