@remit/web-client 0.0.160 → 0.0.162
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 +9 -0
- package/src/components/compose/InlineCompose.tsx +9 -9
- package/src/components/mail/ConversationView.tsx +51 -55
- package/src/components/mail/MessageBody.tsx +2 -2
- package/src/components/mail/MessageCard.tsx +1 -4
- package/src/components/mail/SelectionWizardHost.tsx +5 -3
- package/src/components/mail/conversation-reply-reach.render.test.ts +147 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/web-client",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.162",
|
|
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": {
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ComposeFormShell,
|
|
15
15
|
ComposeHeader,
|
|
16
16
|
type ComposeSendState,
|
|
17
|
+
type ComposeShellLayout,
|
|
17
18
|
ComposeSubjectField,
|
|
18
19
|
composeHeaderSummary,
|
|
19
20
|
defaultComposeLanguages,
|
|
@@ -67,6 +68,12 @@ interface ComposeFormProps {
|
|
|
67
68
|
sourceMessage?: RemitImapDescribeMessageResponse;
|
|
68
69
|
onClose: () => void;
|
|
69
70
|
onAccountChange?: (account: RemitImapAccountResponse) => void;
|
|
71
|
+
/**
|
|
72
|
+
* How the surface takes its height. A window in its own pane fills that
|
|
73
|
+
* pane; one opened as a block of the conversation grows with what is written
|
|
74
|
+
* in it and leaves the pane the only scroller.
|
|
75
|
+
*/
|
|
76
|
+
layout?: ComposeShellLayout;
|
|
70
77
|
}
|
|
71
78
|
|
|
72
79
|
const escapeHtml = (text: string): string =>
|
|
@@ -301,6 +308,7 @@ export const ComposeForm = ({
|
|
|
301
308
|
sourceMessage,
|
|
302
309
|
onClose,
|
|
303
310
|
onAccountChange,
|
|
311
|
+
layout = "fill",
|
|
304
312
|
}: ComposeFormProps) => {
|
|
305
313
|
const { state, setOutboxMessageId, startSendPolling } = useCompose();
|
|
306
314
|
const { pushError } = useErrorBanners();
|
|
@@ -751,6 +759,7 @@ export const ComposeForm = ({
|
|
|
751
759
|
|
|
752
760
|
return (
|
|
753
761
|
<ComposeFormShell
|
|
762
|
+
layout={layout}
|
|
754
763
|
banner={
|
|
755
764
|
selectedAccount && selectedAccountMissingSmtp ? (
|
|
756
765
|
<ComposeSmtpMissingBanner
|
|
@@ -12,22 +12,22 @@ interface InlineComposeProps {
|
|
|
12
12
|
onClose: () => void;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
+
/**
|
|
16
|
+
* The reply, as the top block of the conversation it answers. It takes no
|
|
17
|
+
* height of its own: it is as tall as what has been written in it and pushes
|
|
18
|
+
* the thread down as that grows, so the pane keeps the one scrollbar it had
|
|
19
|
+
* before the reply opened. A height here would give the pane a second one, with
|
|
20
|
+
* the caret in the inner track.
|
|
21
|
+
*/
|
|
15
22
|
export const InlineCompose = ({
|
|
16
23
|
mode,
|
|
17
24
|
account,
|
|
18
25
|
sourceMessage,
|
|
19
26
|
onClose,
|
|
20
27
|
}: InlineComposeProps) => (
|
|
21
|
-
|
|
22
|
-
// in landscape, or one with the keyboard up — a flat 400px is taller than
|
|
23
|
-
// the pane, and the surface's own header scrolls off before its verbs do.
|
|
24
|
-
//
|
|
25
|
-
// A desktop pane has room a phone does not, so the surface takes a height
|
|
26
|
-
// there rather than a ceiling. Left to size itself, the recipient rows and
|
|
27
|
-
// the verbs come first and the writing area gets its 120px floor — a couple
|
|
28
|
-
// of lines to answer a message in.
|
|
29
|
-
<div className="border-t border-line bg-canvas max-h-[min(400px,60dvh)] flex flex-col lg:h-[min(560px,55dvh)] lg:max-h-none">
|
|
28
|
+
<div className="border-b border-line bg-canvas">
|
|
30
29
|
<ComposeForm
|
|
30
|
+
layout="flow"
|
|
31
31
|
mode={mode}
|
|
32
32
|
account={account}
|
|
33
33
|
sourceMessage={sourceMessage}
|
|
@@ -122,8 +122,9 @@ export const ConversationView = ({
|
|
|
122
122
|
error,
|
|
123
123
|
refetch,
|
|
124
124
|
} = useQuery({
|
|
125
|
-
// The API reads a conversation oldest first (#81) and
|
|
126
|
-
//
|
|
125
|
+
// The API reads a conversation oldest first (#81) and no `order` is asked
|
|
126
|
+
// for: which turn came first is the thread's own fact, and which end of it
|
|
127
|
+
// the pane puts at the top is the pane's.
|
|
127
128
|
...threadDetailOperationsListThreadMessagesOptions({
|
|
128
129
|
path: { threadId },
|
|
129
130
|
}),
|
|
@@ -134,6 +135,11 @@ export const ConversationView = ({
|
|
|
134
135
|
[messagesResponse?.items],
|
|
135
136
|
);
|
|
136
137
|
const latestMessage = messages[messages.length - 1];
|
|
138
|
+
|
|
139
|
+
// Newest first, under the reply that answers it. The turn a reader came for
|
|
140
|
+
// is the last one, and reading it should not cost a scroll past everything
|
|
141
|
+
// that led to it.
|
|
142
|
+
const orderedMessages = useMemo(() => [...messages].reverse(), [messages]);
|
|
137
143
|
const messageRefs = useRef<Map<string, HTMLDivElement>>(new Map());
|
|
138
144
|
|
|
139
145
|
// Track which messages are expanded
|
|
@@ -158,7 +164,7 @@ export const ConversationView = ({
|
|
|
158
164
|
if (selected) initialExpanded.add(selected.threadMessageId);
|
|
159
165
|
}
|
|
160
166
|
setExpandedIds(initialExpanded);
|
|
161
|
-
setFocusedIndex(
|
|
167
|
+
setFocusedIndex(0);
|
|
162
168
|
}
|
|
163
169
|
}, [threadId, messages, currentThreadId, selectedMessageId]);
|
|
164
170
|
|
|
@@ -174,38 +180,39 @@ export const ConversationView = ({
|
|
|
174
180
|
});
|
|
175
181
|
}, []);
|
|
176
182
|
|
|
177
|
-
// Scroll focused message into view
|
|
183
|
+
// Scroll focused message into view. j and k walk the pane as it reads, so
|
|
184
|
+
// the index they move is over the displayed order, not the fetched one.
|
|
178
185
|
const scrollToMessage = useCallback(
|
|
179
186
|
(index: number) => {
|
|
180
|
-
const message =
|
|
187
|
+
const message = orderedMessages[index];
|
|
181
188
|
if (!message) return;
|
|
182
189
|
const element = messageRefs.current.get(message.threadMessageId);
|
|
183
190
|
element?.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
|
184
191
|
},
|
|
185
|
-
[
|
|
192
|
+
[orderedMessages],
|
|
186
193
|
);
|
|
187
194
|
|
|
188
195
|
// Keyboard navigation handlers
|
|
189
196
|
const focusNext = useCallback(() => {
|
|
190
|
-
if (
|
|
191
|
-
const nextIndex = Math.min(focusedIndex + 1,
|
|
197
|
+
if (orderedMessages.length === 0) return;
|
|
198
|
+
const nextIndex = Math.min(focusedIndex + 1, orderedMessages.length - 1);
|
|
192
199
|
setFocusedIndex(nextIndex);
|
|
193
200
|
scrollToMessage(nextIndex);
|
|
194
|
-
}, [
|
|
201
|
+
}, [orderedMessages.length, focusedIndex, scrollToMessage]);
|
|
195
202
|
|
|
196
203
|
const focusPrevious = useCallback(() => {
|
|
197
|
-
if (
|
|
204
|
+
if (orderedMessages.length === 0) return;
|
|
198
205
|
const prevIndex = Math.max(focusedIndex - 1, 0);
|
|
199
206
|
setFocusedIndex(prevIndex);
|
|
200
207
|
scrollToMessage(prevIndex);
|
|
201
|
-
}, [
|
|
208
|
+
}, [orderedMessages.length, focusedIndex, scrollToMessage]);
|
|
202
209
|
|
|
203
210
|
const toggleFocusedMessage = useCallback(() => {
|
|
204
|
-
const message =
|
|
211
|
+
const message = orderedMessages[focusedIndex];
|
|
205
212
|
if (message) {
|
|
206
213
|
toggleExpanded(message.threadMessageId);
|
|
207
214
|
}
|
|
208
|
-
}, [
|
|
215
|
+
}, [orderedMessages, focusedIndex, toggleExpanded]);
|
|
209
216
|
|
|
210
217
|
const { data: config } = useQuery({
|
|
211
218
|
...configOperationsGetConfigOptions(),
|
|
@@ -282,25 +289,18 @@ export const ConversationView = ({
|
|
|
282
289
|
onComposeClose?.();
|
|
283
290
|
}, [onComposeClose]);
|
|
284
291
|
|
|
285
|
-
// Compose opens
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
//
|
|
289
|
-
//
|
|
290
|
-
// been fetched and grows when that lands, so a single scroll at open time
|
|
291
|
-
// puts whatever it grows past back below the fold.
|
|
292
|
+
// Compose opens at the head of the pane, which from halfway down a long
|
|
293
|
+
// thread is several screens up: without this, replying looks like nothing
|
|
294
|
+
// happening. Aligned on its top edge, where the recipients are, and left
|
|
295
|
+
// alone afterwards — it grows downward as it is written into, so following
|
|
296
|
+
// its height would pull the rows being typed into off the top.
|
|
292
297
|
const composeRef = useRef<HTMLDivElement>(null);
|
|
293
298
|
useEffect(() => {
|
|
294
299
|
if (composeMode === null || composeOpenings === 0) return;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
reveal();
|
|
300
|
-
if (typeof ResizeObserver === "undefined") return;
|
|
301
|
-
const observer = new ResizeObserver(reveal);
|
|
302
|
-
observer.observe(surface);
|
|
303
|
-
return () => observer.disconnect();
|
|
300
|
+
composeRef.current?.scrollIntoView({
|
|
301
|
+
behavior: "smooth",
|
|
302
|
+
block: "start",
|
|
303
|
+
});
|
|
304
304
|
}, [composeOpenings, composeMode]);
|
|
305
305
|
|
|
306
306
|
// Register keyboard shortcuts
|
|
@@ -357,8 +357,8 @@ export const ConversationView = ({
|
|
|
357
357
|
// wired whatever the account's SMTP state: compose is where an account that
|
|
358
358
|
// cannot send says so.
|
|
359
359
|
const renderMessages = (mobile: boolean) => (
|
|
360
|
-
<div>
|
|
361
|
-
{
|
|
360
|
+
<div data-testid="conversation-messages">
|
|
361
|
+
{orderedMessages.map((message, index) => (
|
|
362
362
|
<div
|
|
363
363
|
key={message.threadMessageId}
|
|
364
364
|
ref={(el) => {
|
|
@@ -385,6 +385,21 @@ export const ConversationView = ({
|
|
|
385
385
|
</div>
|
|
386
386
|
);
|
|
387
387
|
|
|
388
|
+
// The reply leads the pane, above the turn it answers: what is being written
|
|
389
|
+
// is what the reader came back for, and it is not something to go looking
|
|
390
|
+
// for under a thread. It scrolls with the conversation rather than floating
|
|
391
|
+
// over it, so the pane has one scrollbar whether or not a reply is open.
|
|
392
|
+
const compose = composeMode !== null && (
|
|
393
|
+
<div ref={composeRef}>
|
|
394
|
+
<InlineCompose
|
|
395
|
+
mode={composeMode}
|
|
396
|
+
account={activeAccount}
|
|
397
|
+
sourceMessage={latestMessageData}
|
|
398
|
+
onClose={handleCloseCompose}
|
|
399
|
+
/>
|
|
400
|
+
</div>
|
|
401
|
+
);
|
|
402
|
+
|
|
388
403
|
// Subject block: matches the AppShell ReadingPane reference exactly —
|
|
389
404
|
// px-5 pt-5 pb-3, text-lg leading-snug, 2xs count. Subject scrolls
|
|
390
405
|
// with the thread body; no subject in the toolbar chrome.
|
|
@@ -419,17 +434,8 @@ export const ConversationView = ({
|
|
|
419
434
|
onOpenIntelligence={onOpenIntelligence}
|
|
420
435
|
/>
|
|
421
436
|
)}
|
|
437
|
+
{compose}
|
|
422
438
|
{renderMessages(true)}
|
|
423
|
-
{composeMode !== null && (
|
|
424
|
-
<div ref={composeRef}>
|
|
425
|
-
<InlineCompose
|
|
426
|
-
mode={composeMode}
|
|
427
|
-
account={activeAccount}
|
|
428
|
-
sourceMessage={latestMessageData}
|
|
429
|
-
onClose={handleCloseCompose}
|
|
430
|
-
/>
|
|
431
|
-
</div>
|
|
432
|
-
)}
|
|
433
439
|
</MobileReadingPane>
|
|
434
440
|
);
|
|
435
441
|
}
|
|
@@ -443,22 +449,12 @@ export const ConversationView = ({
|
|
|
443
449
|
onOpenIntelligence={onOpenIntelligence}
|
|
444
450
|
/>
|
|
445
451
|
)}
|
|
446
|
-
{/* The
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
reach it. */}
|
|
452
|
+
{/* The one scroller in the pane. The reply and the thread are both in
|
|
453
|
+
it, so neither has a scrollbar of its own and neither is squeezed
|
|
454
|
+
by the height the other takes. */}
|
|
450
455
|
<div className="min-h-0 flex-1 overflow-auto">
|
|
456
|
+
{compose}
|
|
451
457
|
{renderMessages(false)}
|
|
452
|
-
{composeMode !== null && (
|
|
453
|
-
<div ref={composeRef}>
|
|
454
|
-
<InlineCompose
|
|
455
|
-
mode={composeMode}
|
|
456
|
-
account={activeAccount}
|
|
457
|
-
sourceMessage={latestMessageData}
|
|
458
|
-
onClose={handleCloseCompose}
|
|
459
|
-
/>
|
|
460
|
-
</div>
|
|
461
|
-
)}
|
|
462
458
|
</div>
|
|
463
459
|
</article>
|
|
464
460
|
);
|
|
@@ -68,8 +68,8 @@ interface MessageBodyProps {
|
|
|
68
68
|
category?: EmailRenderCategory;
|
|
69
69
|
/**
|
|
70
70
|
* Extra classes for the `.message-body` wrapper. `MessageCard` leaves this
|
|
71
|
-
* unset: the kit's `ExpandedMessage`
|
|
72
|
-
*
|
|
71
|
+
* unset: the kit's `ExpandedMessage` owns the gutter, and runs the sandboxed
|
|
72
|
+
* email frame out of it so mail renders on its own ground.
|
|
73
73
|
*/
|
|
74
74
|
className?: string;
|
|
75
75
|
}
|
|
@@ -324,14 +324,11 @@ const ExpandedCard = ({
|
|
|
324
324
|
isTrusted={isTrusted}
|
|
325
325
|
category={toDisplayCategory(threadMessage.category)}
|
|
326
326
|
/>
|
|
327
|
-
{/* The body slot runs edge to edge on a phone; the attachment
|
|
328
|
-
list is app chrome, not part of the email, so it takes the
|
|
329
|
-
gutter back. */}
|
|
330
327
|
<MessageAttachments
|
|
331
328
|
messageId={threadMessage.messageId}
|
|
332
329
|
bodyParts={messageData?.bodyParts}
|
|
333
330
|
hasAttachment={threadMessage.hasAttachment}
|
|
334
|
-
className="mt-4
|
|
331
|
+
className="mt-4"
|
|
335
332
|
/>
|
|
336
333
|
</div>
|
|
337
334
|
)
|
|
@@ -449,9 +449,10 @@ function SelectionWizardSession({
|
|
|
449
449
|
// is how a footer comes to name a screen nobody is looking at.
|
|
450
450
|
const current = steps[stepIndex(steps, step)];
|
|
451
451
|
|
|
452
|
-
// A filter
|
|
453
|
-
//
|
|
454
|
-
//
|
|
452
|
+
// A filter, a folder, and the preview that counts a widened door all belong to
|
|
453
|
+
// one account, so a selection spanning accounts reaches none of them and is
|
|
454
|
+
// told so on the step that asks (#477 5.5, #523). The one-off scope and the
|
|
455
|
+
// ticked rows act on the messages themselves and are unaffected.
|
|
455
456
|
const accountScoped = !crossAccount && !!accountId;
|
|
456
457
|
const ruleRestriction = accountScoped ? undefined : crossAccountRuleReason;
|
|
457
458
|
const folderRestriction = accountScoped
|
|
@@ -939,6 +940,7 @@ function SelectionWizardSession({
|
|
|
939
940
|
match={{
|
|
940
941
|
selectedCount: selection.length,
|
|
941
942
|
mode,
|
|
943
|
+
accountId: accountScoped ? accountId : undefined,
|
|
942
944
|
onModeChange: changeMode,
|
|
943
945
|
semanticUnavailable,
|
|
944
946
|
semanticErrorDetail:
|
|
@@ -1,15 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Reading a message and answering it, on a desktop pane.
|
|
3
3
|
*
|
|
4
|
-
* The
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the
|
|
4
|
+
* The reply leads the pane and the thread reads newest first under it, so what
|
|
5
|
+
* is being written and the turn it answers are the two things at the top. Both
|
|
6
|
+
* live in the pane's own scrolling region: the reply has no height of its own
|
|
7
|
+
* and no scroller of its own, which is what kept a second scrollbar — with the
|
|
8
|
+
* caret in the inner one — out of a single column.
|
|
9
9
|
*
|
|
10
|
-
* The
|
|
11
|
-
* beside the sender is the control that puts a message away — reclaiming the
|
|
12
|
-
* space is what a reader reaches for it to do.
|
|
10
|
+
* The chevron beside the sender is the control that puts a message away.
|
|
13
11
|
*/
|
|
14
12
|
|
|
15
13
|
import assert from "node:assert/strict";
|
|
@@ -41,6 +39,24 @@ const MESSAGE_ID = "msg-1";
|
|
|
41
39
|
|
|
42
40
|
const account = makeAccount({ accountId: ACCOUNT_ID });
|
|
43
41
|
|
|
42
|
+
const OPENING_SENT = 1_767_139_200_000;
|
|
43
|
+
const LATEST_SENT = 1_767_225_600_000;
|
|
44
|
+
|
|
45
|
+
// The turn that opened the conversation, and the one that answered it. The API
|
|
46
|
+
// hands them over oldest first (#81), which is the order this mock serves them
|
|
47
|
+
// in — the pane is what decides which end of that goes at the top.
|
|
48
|
+
const openingMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
49
|
+
messageId: "msg-0",
|
|
50
|
+
threadId: THREAD_ID,
|
|
51
|
+
mailboxId: MAILBOX_ID,
|
|
52
|
+
accountId: ACCOUNT_ID,
|
|
53
|
+
subject: "Lunch Thursday?",
|
|
54
|
+
fromName: "Grace Hopper",
|
|
55
|
+
fromEmail: "grace@example.com",
|
|
56
|
+
sentDate: OPENING_SENT,
|
|
57
|
+
isRead: true,
|
|
58
|
+
});
|
|
59
|
+
|
|
44
60
|
const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
45
61
|
messageId: MESSAGE_ID,
|
|
46
62
|
threadId: THREAD_ID,
|
|
@@ -49,6 +65,7 @@ const threadMessage: RemitImapThreadMessageResponse = makeThreadMessage({
|
|
|
49
65
|
subject: "Lunch Thursday?",
|
|
50
66
|
fromName: "Ada Lovelace",
|
|
51
67
|
fromEmail: "ada@example.com",
|
|
68
|
+
sentDate: LATEST_SENT,
|
|
52
69
|
// Already read: marking one read on open is a mutation this test has no
|
|
53
70
|
// business driving.
|
|
54
71
|
isRead: true,
|
|
@@ -141,7 +158,7 @@ const mount = async (): Promise<DomHarness> => {
|
|
|
141
158
|
http = mockFetch((call) => {
|
|
142
159
|
if (call.path.endsWith("/config")) return { accounts: [account] };
|
|
143
160
|
if (call.path.endsWith(`/threads/${THREAD_ID}/messages`)) {
|
|
144
|
-
return { items: [threadMessage] };
|
|
161
|
+
return { items: [openingMessage, threadMessage] };
|
|
145
162
|
}
|
|
146
163
|
if (call.path.endsWith(`/messages/${MESSAGE_ID}`)) return describeMessage;
|
|
147
164
|
return { items: [] };
|
|
@@ -176,6 +193,35 @@ const pressReply = async (mounted: DomHarness): Promise<void> => {
|
|
|
176
193
|
const paneRegionHolding = (pane: Element, node: Node): Element | null =>
|
|
177
194
|
[...pane.children].find((child) => child.contains(node)) ?? null;
|
|
178
195
|
|
|
196
|
+
/**
|
|
197
|
+
* The utilities that make an element scroll vertically. A horizontal one — the
|
|
198
|
+
* formatting toolbar's strip of buttons — is a different thing and is not
|
|
199
|
+
* counted: it holds a row that would otherwise be cut off, not a column of
|
|
200
|
+
* content the reader moves through.
|
|
201
|
+
*/
|
|
202
|
+
const SCROLLS_VERTICALLY = /(^|\s)overflow-(auto|scroll|y-auto|y-scroll)(\s|$)/;
|
|
203
|
+
|
|
204
|
+
/** The inset ring `MessageCard` draws on the row the keyboard is sitting on. */
|
|
205
|
+
const FOCUS_RING = /ring-accent\//;
|
|
206
|
+
|
|
207
|
+
/** The turn the keyboard has, read off the ring rather than off the index. */
|
|
208
|
+
const focusedTurn = (mounted: DomHarness): string => {
|
|
209
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
210
|
+
assert.ok(thread, "the thread is on screen");
|
|
211
|
+
const focused = [...thread.children].filter((card) =>
|
|
212
|
+
[...card.querySelectorAll("*")].some((node) =>
|
|
213
|
+
FOCUS_RING.test(node.getAttribute("class") ?? ""),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
assert.equal(focused.length, 1, "one turn carries the keyboard focus");
|
|
217
|
+
return focused[0]?.textContent ?? "";
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const verticalScrollers = (root: Element): Element[] =>
|
|
221
|
+
[root, ...root.querySelectorAll("*")].filter((node) =>
|
|
222
|
+
SCROLLS_VERTICALLY.test(node.getAttribute("class") ?? ""),
|
|
223
|
+
);
|
|
224
|
+
|
|
179
225
|
describe("answering the message that is open", () => {
|
|
180
226
|
it("puts the reply in the same scrolling region as the message", async () => {
|
|
181
227
|
const mounted = await mount();
|
|
@@ -199,6 +245,94 @@ describe("answering the message that is open", () => {
|
|
|
199
245
|
);
|
|
200
246
|
});
|
|
201
247
|
|
|
248
|
+
it("leads the pane with the reply, above the thread it answers", async () => {
|
|
249
|
+
const mounted = await mount();
|
|
250
|
+
|
|
251
|
+
await pressReply(mounted);
|
|
252
|
+
|
|
253
|
+
const compose = mounted.query('[data-testid="compose-body-area"]');
|
|
254
|
+
assert.ok(compose, "the reply opened");
|
|
255
|
+
|
|
256
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
257
|
+
assert.ok(thread, "the thread is on screen");
|
|
258
|
+
|
|
259
|
+
const following = mounted.window.Node.DOCUMENT_POSITION_FOLLOWING;
|
|
260
|
+
assert.ok(
|
|
261
|
+
(compose.compareDocumentPosition(thread) & following) === following,
|
|
262
|
+
"the reply comes before the messages, not after the last of them",
|
|
263
|
+
);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it("reads the thread newest first", async () => {
|
|
267
|
+
const mounted = await mount();
|
|
268
|
+
|
|
269
|
+
const thread = mounted.query('[data-testid="conversation-messages"]');
|
|
270
|
+
assert.ok(thread, "the thread is on screen");
|
|
271
|
+
|
|
272
|
+
const cards = [...thread.children].map((card) => card.textContent ?? "");
|
|
273
|
+
assert.equal(cards.length, 2, "both turns are on screen");
|
|
274
|
+
assert.ok(
|
|
275
|
+
cards[0]?.includes("Ada Lovelace"),
|
|
276
|
+
"the turn that answered the conversation is at the top",
|
|
277
|
+
);
|
|
278
|
+
assert.ok(
|
|
279
|
+
cards[1]?.includes("Grace Hopper"),
|
|
280
|
+
"the turn that opened it is below",
|
|
281
|
+
);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("walks the thread with j and k in the order it is displayed in", async () => {
|
|
285
|
+
const mounted = await mount();
|
|
286
|
+
|
|
287
|
+
assert.ok(
|
|
288
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
289
|
+
"the keyboard starts on the turn at the top, which is the latest one",
|
|
290
|
+
);
|
|
291
|
+
|
|
292
|
+
const press = async (key: string): Promise<void> => {
|
|
293
|
+
mounted.dispatch(
|
|
294
|
+
mounted.window,
|
|
295
|
+
new mounted.window.KeyboardEvent("keydown", { key, bubbles: true }),
|
|
296
|
+
);
|
|
297
|
+
await mounted.flush();
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
// Down the pane is back in time now that the thread reads newest first.
|
|
301
|
+
// A reorder that left the handlers alone would invert this silently, and
|
|
302
|
+
// the help overlay describes the same direction this asserts.
|
|
303
|
+
await press("j");
|
|
304
|
+
assert.ok(
|
|
305
|
+
focusedTurn(mounted).includes("Grace Hopper"),
|
|
306
|
+
"j moves to the turn below, which is the older one",
|
|
307
|
+
);
|
|
308
|
+
|
|
309
|
+
await press("k");
|
|
310
|
+
assert.ok(
|
|
311
|
+
focusedTurn(mounted).includes("Ada Lovelace"),
|
|
312
|
+
"k moves to the turn above, which is the newer one",
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("gives the pane one scrollbar, reply open or not", async () => {
|
|
317
|
+
const mounted = await mount();
|
|
318
|
+
|
|
319
|
+
const pane = mounted.query("article");
|
|
320
|
+
assert.ok(pane, "the conversation pane is mounted");
|
|
321
|
+
assert.equal(
|
|
322
|
+
verticalScrollers(pane).length,
|
|
323
|
+
1,
|
|
324
|
+
"reading a thread scrolls one thing",
|
|
325
|
+
);
|
|
326
|
+
|
|
327
|
+
await pressReply(mounted);
|
|
328
|
+
|
|
329
|
+
assert.equal(
|
|
330
|
+
verticalScrollers(pane).length,
|
|
331
|
+
1,
|
|
332
|
+
"the reply is a block of the pane, not a box with a scrollbar of its own next to the editor",
|
|
333
|
+
);
|
|
334
|
+
});
|
|
335
|
+
|
|
202
336
|
it("collapses the message from the chevron beside the sender", async () => {
|
|
203
337
|
const mounted = await mount();
|
|
204
338
|
|
|
@@ -215,9 +349,10 @@ describe("answering the message that is open", () => {
|
|
|
215
349
|
mounted.query('[aria-label="Collapse message"]') === null,
|
|
216
350
|
"the chevron collapsed the message it belongs to",
|
|
217
351
|
);
|
|
218
|
-
assert.
|
|
219
|
-
mounted.
|
|
220
|
-
|
|
352
|
+
assert.equal(
|
|
353
|
+
mounted.queryAll('[role="button"][aria-expanded="false"]').length,
|
|
354
|
+
2,
|
|
355
|
+
"the message is back to a collapsed row, beside the one that was already collapsed",
|
|
221
356
|
);
|
|
222
357
|
});
|
|
223
358
|
});
|