@linktr.ee/messaging-react 4.4.3 → 4.4.4-rc-1788190776
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/dist/AttachmentCard-BbsjzdLJ.cjs +2 -0
- package/dist/AttachmentCard-BbsjzdLJ.cjs.map +1 -0
- package/dist/{AttachmentCard-BqwWgqvi.js → AttachmentCard-DSekwIOA.js} +35 -20
- package/dist/AttachmentCard-DSekwIOA.js.map +1 -0
- package/dist/{Card-BIbqWagz.cjs → Card-BS6Fza2a.cjs} +2 -2
- package/dist/{Card-BIbqWagz.cjs.map → Card-BS6Fza2a.cjs.map} +1 -1
- package/dist/{Card-f51K6V7Y.js → Card-BhwBkgxI.js} +2 -2
- package/dist/{Card-f51K6V7Y.js.map → Card-BhwBkgxI.js.map} +1 -1
- package/dist/index.cjs +2 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +67 -42
- package/dist/index.js.map +1 -1
- package/package.json +3 -3
- package/src/components/AttachmentCard/MediaPlayer.test.tsx +62 -85
- package/src/components/AttachmentCard/MediaPlayer.tsx +14 -2
- package/src/components/AttachmentCard/Thumbnail.test.tsx +37 -46
- package/src/components/AttachmentCard/index.test.tsx +18 -46
- package/src/components/AttachmentCard/index.tsx +8 -2
- package/src/components/AttachmentCard/utils/icons.ts +11 -1
- package/src/components/Avatar/Avatar.test.tsx +37 -64
- package/src/components/Avatar/index.tsx +8 -1
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +19 -19
- package/src/components/ChannelList/CustomChannelPreview.tsx +8 -2
- package/src/components/ChannelView.outbound-resolution.integration.test.tsx +25 -30
- package/src/components/ChannelView.test.tsx +17 -31
- package/src/components/ChannelView.tsx +1 -0
- package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +8 -5
- package/src/components/CustomLinkPreviewList/index.tsx +4 -1
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +26 -21
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +2 -0
- package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +43 -56
- package/src/components/CustomMessageInput/index.tsx +25 -5
- package/src/components/CustomSystemMessage/CustomSystemMessage.test.tsx +17 -23
- package/src/components/FaqList/FaqList.test.tsx +5 -4
- package/src/components/FaqList/index.tsx +6 -1
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +70 -81
- package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +20 -61
- package/src/components/LinkAttachment/components/_shared/CardBody.tsx +10 -2
- package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +1 -21
- package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +11 -37
- package/src/components/LinkAttachment/components/_shared/CardShell.tsx +5 -2
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +65 -72
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.tsx +13 -2
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +25 -8
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +5 -19
- package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +7 -9
- package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +9 -2
- package/src/components/MessageAttachment/_shared/VideoViewer.tsx +1 -0
- package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +2 -2
- package/src/components/MessageBubble/MessageBubble.test.tsx +0 -1
- package/src/components/RichCard/RichCard.test.tsx +24 -54
- package/src/components/RichCard/RichCardBody.tsx +4 -1
- package/src/hooks/useChannelModerationActions.ts +15 -30
- package/src/logging/index.test.tsx +1 -0
- package/src/providers/MessagingProvider.test.tsx +37 -41
- package/dist/AttachmentCard-BqwWgqvi.js.map +0 -1
- package/dist/AttachmentCard-DUrgGRsq.cjs +0 -2
- package/dist/AttachmentCard-DUrgGRsq.cjs.map +0 -1
|
@@ -2,7 +2,12 @@ import React from 'react'
|
|
|
2
2
|
import type { Attachment, LocalMessage } from 'stream-chat'
|
|
3
3
|
import { describe, expect, it } from 'vitest'
|
|
4
4
|
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
fireEvent,
|
|
7
|
+
renderWithProviders,
|
|
8
|
+
screen,
|
|
9
|
+
within,
|
|
10
|
+
} from '../../test/utils'
|
|
6
11
|
|
|
7
12
|
import StreamAttachmentMessage, {
|
|
8
13
|
linkCaptionDuplicatesAttachmentUrl,
|
|
@@ -105,7 +110,7 @@ describe('StreamAttachmentMessage', () => {
|
|
|
105
110
|
})
|
|
106
111
|
|
|
107
112
|
it('keeps an emoji-only caption instead of dropping it', () => {
|
|
108
|
-
|
|
113
|
+
renderWithProviders(
|
|
109
114
|
<StreamAttachmentMessage
|
|
110
115
|
groupPosition="single"
|
|
111
116
|
isMyMessage={false}
|
|
@@ -124,15 +129,13 @@ describe('StreamAttachmentMessage', () => {
|
|
|
124
129
|
|
|
125
130
|
// MessageText linkifies the URL (display text drops the scheme), so the
|
|
126
131
|
// caption is split across a link node and trailing emoji text.
|
|
127
|
-
const captionBubble =
|
|
128
|
-
'.str-chat__message-bubble:not([data-attachment-bubble])'
|
|
129
|
-
)
|
|
132
|
+
const captionBubble = screen.getByTestId('message-caption-bubble')
|
|
130
133
|
expect(captionBubble).toHaveTextContent('linktr.ee/someone')
|
|
131
134
|
expect(captionBubble).toHaveTextContent('😎')
|
|
132
135
|
})
|
|
133
136
|
|
|
134
137
|
it('renders mixed link and media attachments in order', () => {
|
|
135
|
-
|
|
138
|
+
renderWithProviders(
|
|
136
139
|
<StreamAttachmentMessage
|
|
137
140
|
groupPosition="single"
|
|
138
141
|
isMyMessage={true}
|
|
@@ -160,7 +163,9 @@ describe('StreamAttachmentMessage', () => {
|
|
|
160
163
|
expect(link.compareDocumentPosition(imageBubble)).toBe(
|
|
161
164
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
162
165
|
)
|
|
163
|
-
expect(
|
|
166
|
+
expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
|
|
167
|
+
'caption'
|
|
168
|
+
)
|
|
164
169
|
})
|
|
165
170
|
|
|
166
171
|
it('optimizes Linktree-hosted image attachments for the channel bubble', () => {
|
|
@@ -203,7 +208,9 @@ describe('StreamAttachmentMessage', () => {
|
|
|
203
208
|
// Video posters were the last media URL leaving here at full size —
|
|
204
209
|
// four of them per 2×2 grid.
|
|
205
210
|
expect(
|
|
206
|
-
screen.getByTestId('video-attachment').
|
|
211
|
+
within(screen.getByTestId('video-attachment')).getByRole('img', {
|
|
212
|
+
hidden: true,
|
|
213
|
+
})
|
|
207
214
|
).toHaveAttribute(
|
|
208
215
|
'src',
|
|
209
216
|
'https://ugc.production.linktr.ee/poster.jpg?io=true&size=messaging-attachment-v1_0'
|
|
@@ -229,13 +236,14 @@ describe('StreamAttachmentMessage', () => {
|
|
|
229
236
|
const viewer = screen.getByTestId('image-viewer')
|
|
230
237
|
|
|
231
238
|
expect(viewer).toHaveAttribute('open')
|
|
232
|
-
expect(viewer.
|
|
239
|
+
expect(within(viewer).getByRole('img', { hidden: true })).toHaveAttribute(
|
|
240
|
+
'src',
|
|
233
241
|
'https://ugc.production.linktr.ee/photo.jpg'
|
|
234
242
|
)
|
|
235
243
|
})
|
|
236
244
|
|
|
237
245
|
it('renders the accompanying message text next to a link attachment', () => {
|
|
238
|
-
|
|
246
|
+
renderWithProviders(
|
|
239
247
|
<StreamAttachmentMessage
|
|
240
248
|
groupPosition="single"
|
|
241
249
|
isMyMessage={true}
|
|
@@ -252,14 +260,14 @@ describe('StreamAttachmentMessage', () => {
|
|
|
252
260
|
/>
|
|
253
261
|
)
|
|
254
262
|
|
|
255
|
-
|
|
256
|
-
expect(
|
|
257
|
-
|
|
258
|
-
|
|
263
|
+
expect(screen.getByTestId('message-attachment-bubble')).toBeInTheDocument()
|
|
264
|
+
expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
|
|
265
|
+
'you have to see this'
|
|
266
|
+
)
|
|
259
267
|
})
|
|
260
268
|
|
|
261
269
|
it('renders the accompanying message text next to a media attachment', () => {
|
|
262
|
-
|
|
270
|
+
renderWithProviders(
|
|
263
271
|
<StreamAttachmentMessage
|
|
264
272
|
groupPosition="single"
|
|
265
273
|
isMyMessage={false}
|
|
@@ -276,11 +284,8 @@ describe('StreamAttachmentMessage', () => {
|
|
|
276
284
|
/>
|
|
277
285
|
)
|
|
278
286
|
|
|
279
|
-
expect(
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
'.str-chat__message-bubble:not([data-attachment-bubble])'
|
|
283
|
-
)
|
|
284
|
-
).toHaveTextContent('from the trip')
|
|
287
|
+
expect(screen.getByTestId('message-caption-bubble')).toHaveTextContent(
|
|
288
|
+
'from the trip'
|
|
289
|
+
)
|
|
285
290
|
})
|
|
286
291
|
})
|
|
@@ -408,6 +408,7 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
|
|
|
408
408
|
<div
|
|
409
409
|
className="str-chat__message-bubble"
|
|
410
410
|
data-attachment-bubble="true"
|
|
411
|
+
data-testid="message-attachment-bubble"
|
|
411
412
|
style={{
|
|
412
413
|
background: 'transparent',
|
|
413
414
|
borderRadius: 0,
|
|
@@ -420,6 +421,7 @@ const StreamAttachmentMessage: React.FC<StreamAttachmentMessageProps> = ({
|
|
|
420
421
|
</div>
|
|
421
422
|
{caption ? (
|
|
422
423
|
<div
|
|
424
|
+
data-testid="message-caption-bubble"
|
|
423
425
|
className={classNames('str-chat__message-bubble', {
|
|
424
426
|
'relative isolate': captionShowsTail,
|
|
425
427
|
})}
|
|
@@ -88,9 +88,9 @@ describe('CustomMessageInput', () => {
|
|
|
88
88
|
it('renders the interactive message input when channel is not frozen', () => {
|
|
89
89
|
mockChannelData = {}
|
|
90
90
|
|
|
91
|
-
|
|
91
|
+
renderWithProviders(<CustomMessageInput />)
|
|
92
92
|
|
|
93
|
-
const messageInput =
|
|
93
|
+
const messageInput = screen.getByTestId('message-input')
|
|
94
94
|
expect(messageInput).not.toHaveAttribute('aria-disabled')
|
|
95
95
|
expect(messageInput).not.toHaveAttribute('inert')
|
|
96
96
|
expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
|
|
@@ -99,9 +99,9 @@ describe('CustomMessageInput', () => {
|
|
|
99
99
|
it('renders the frozen message input when channel is frozen', () => {
|
|
100
100
|
mockChannelData = { frozen: true }
|
|
101
101
|
|
|
102
|
-
|
|
102
|
+
renderWithProviders(<CustomMessageInput />)
|
|
103
103
|
|
|
104
|
-
const messageInput =
|
|
104
|
+
const messageInput = screen.getByTestId('message-input')
|
|
105
105
|
expect(messageInput).toHaveAttribute('aria-disabled', 'true')
|
|
106
106
|
expect(messageInput).toHaveAttribute('inert')
|
|
107
107
|
expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
|
|
@@ -125,7 +125,7 @@ describe('CustomMessageInput', () => {
|
|
|
125
125
|
it('renders adjacent actions inside the disabled container when frozen', () => {
|
|
126
126
|
mockChannelData = { frozen: true }
|
|
127
127
|
|
|
128
|
-
|
|
128
|
+
renderWithProviders(
|
|
129
129
|
<CustomMessageInput
|
|
130
130
|
renderActions={() => (
|
|
131
131
|
<button data-testid="custom-action" type="button">
|
|
@@ -135,17 +135,16 @@ describe('CustomMessageInput', () => {
|
|
|
135
135
|
/>
|
|
136
136
|
)
|
|
137
137
|
|
|
138
|
-
const messageInput =
|
|
139
|
-
const action = screen.getByTestId('custom-action')
|
|
138
|
+
const messageInput = screen.getByTestId('message-input')
|
|
140
139
|
|
|
141
140
|
expect(messageInput).toHaveAttribute('aria-disabled', 'true')
|
|
142
|
-
expect(messageInput
|
|
141
|
+
expect(messageInput).toContainElement(screen.getByTestId('custom-action'))
|
|
143
142
|
})
|
|
144
143
|
|
|
145
144
|
it('renders adjacent actions in the interactive container when not frozen', () => {
|
|
146
145
|
mockChannelData = {}
|
|
147
146
|
|
|
148
|
-
|
|
147
|
+
renderWithProviders(
|
|
149
148
|
<CustomMessageInput
|
|
150
149
|
renderActions={() => (
|
|
151
150
|
<button data-testid="custom-action" type="button">
|
|
@@ -155,8 +154,9 @@ describe('CustomMessageInput', () => {
|
|
|
155
154
|
/>
|
|
156
155
|
)
|
|
157
156
|
|
|
158
|
-
|
|
159
|
-
|
|
157
|
+
expect(screen.getByTestId('message-input')).not.toHaveAttribute(
|
|
158
|
+
'aria-disabled'
|
|
159
|
+
)
|
|
160
160
|
expect(screen.getByTestId('custom-action')).toBeInTheDocument()
|
|
161
161
|
})
|
|
162
162
|
|
|
@@ -233,7 +233,7 @@ describe('CustomMessageInput', () => {
|
|
|
233
233
|
it('replaces the composer with the locked panel when disabled (channel not frozen)', () => {
|
|
234
234
|
mockChannelData = {}
|
|
235
235
|
|
|
236
|
-
|
|
236
|
+
renderWithProviders(
|
|
237
237
|
<CustomMessageInput
|
|
238
238
|
disabled
|
|
239
239
|
disabledReason="Only Linktree can send messages on this thread"
|
|
@@ -243,12 +243,10 @@ describe('CustomMessageInput', () => {
|
|
|
243
243
|
// The interactive input is gone entirely — no textarea, no send button.
|
|
244
244
|
expect(screen.queryByTestId('stream-message-input')).not.toBeInTheDocument()
|
|
245
245
|
expect(screen.queryByTestId('textarea-composer')).not.toBeInTheDocument()
|
|
246
|
-
expect(
|
|
246
|
+
expect(screen.queryByTestId('message-input')).not.toBeInTheDocument()
|
|
247
247
|
|
|
248
248
|
// The locked panel with the reason replaces it.
|
|
249
|
-
expect(
|
|
250
|
-
container.querySelector('.messaging-composer-locked-panel')
|
|
251
|
-
).toBeInTheDocument()
|
|
249
|
+
expect(screen.getByTestId('composer-locked-panel')).toBeInTheDocument()
|
|
252
250
|
expect(
|
|
253
251
|
screen.getByText(/Only Linktree can send messages on this thread/i)
|
|
254
252
|
).toBeInTheDocument()
|
|
@@ -276,24 +274,19 @@ describe('CustomMessageInput', () => {
|
|
|
276
274
|
it('renders renderHeader above the floating composer controls', () => {
|
|
277
275
|
mockChannelData = {}
|
|
278
276
|
|
|
279
|
-
|
|
277
|
+
renderWithProviders(
|
|
280
278
|
<CustomMessageInput
|
|
281
279
|
composerInput={() => <div data-testid="custom-composer" />}
|
|
282
280
|
renderHeader={() => <div data-testid="composer-header">header</div>}
|
|
283
281
|
/>
|
|
284
282
|
)
|
|
285
283
|
|
|
286
|
-
const chrome = container.querySelector(
|
|
287
|
-
'.messaging-composer-chrome--floating'
|
|
288
|
-
) as HTMLElement | null
|
|
289
284
|
const header = screen.getByTestId('composer-header')
|
|
290
|
-
const messageInput =
|
|
285
|
+
const messageInput = screen.getByTestId('message-input')
|
|
291
286
|
|
|
292
|
-
expect(chrome).
|
|
293
|
-
expect(chrome).toContainElement(header)
|
|
294
|
-
expect(messageInput).not.toBeNull()
|
|
287
|
+
expect(screen.getByTestId('composer-chrome')).toContainElement(header)
|
|
295
288
|
expect(
|
|
296
|
-
header.compareDocumentPosition(messageInput
|
|
289
|
+
header.compareDocumentPosition(messageInput) &
|
|
297
290
|
Node.DOCUMENT_POSITION_FOLLOWING
|
|
298
291
|
).toBeTruthy()
|
|
299
292
|
})
|
|
@@ -301,13 +294,13 @@ describe('CustomMessageInput', () => {
|
|
|
301
294
|
it('keeps the frozen wrapper around a custom composerInput', () => {
|
|
302
295
|
mockChannelData = { frozen: true }
|
|
303
296
|
|
|
304
|
-
|
|
297
|
+
renderWithProviders(
|
|
305
298
|
<CustomMessageInput
|
|
306
299
|
composerInput={() => <div data-testid="custom-composer" />}
|
|
307
300
|
/>
|
|
308
301
|
)
|
|
309
302
|
|
|
310
|
-
const messageInput =
|
|
303
|
+
const messageInput = screen.getByTestId('message-input')
|
|
311
304
|
expect(messageInput).toHaveAttribute('aria-disabled', 'true')
|
|
312
305
|
expect(messageInput).toHaveAttribute('inert')
|
|
313
306
|
expect(screen.getByTestId('custom-composer')).toBeInTheDocument()
|
|
@@ -316,12 +309,10 @@ describe('CustomMessageInput', () => {
|
|
|
316
309
|
it('does not render the locked panel when the composer is not disabled', () => {
|
|
317
310
|
mockChannelData = {}
|
|
318
311
|
|
|
319
|
-
|
|
320
|
-
<CustomMessageInput disabledReason="should not show" />
|
|
321
|
-
)
|
|
312
|
+
renderWithProviders(<CustomMessageInput disabledReason="should not show" />)
|
|
322
313
|
|
|
323
314
|
expect(
|
|
324
|
-
|
|
315
|
+
screen.queryByTestId('composer-locked-panel')
|
|
325
316
|
).not.toBeInTheDocument()
|
|
326
317
|
expect(screen.queryByText(/should not show/i)).not.toBeInTheDocument()
|
|
327
318
|
expect(screen.getByTestId('stream-message-input')).toBeInTheDocument()
|
|
@@ -359,13 +350,13 @@ describe('CustomMessageInput', () => {
|
|
|
359
350
|
vi.unstubAllGlobals()
|
|
360
351
|
})
|
|
361
352
|
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
return
|
|
353
|
+
const mountCard = () => {
|
|
354
|
+
renderWithProviders(<CustomMessageInput />)
|
|
355
|
+
return screen.getByTestId('composer-card')
|
|
365
356
|
}
|
|
366
357
|
|
|
367
358
|
it('caps the card against the surface height and scrolls it as one region', () => {
|
|
368
|
-
const card =
|
|
359
|
+
const card = mountCard()
|
|
369
360
|
|
|
370
361
|
expect(card).toHaveClass(
|
|
371
362
|
'max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)]',
|
|
@@ -375,16 +366,13 @@ describe('CustomMessageInput', () => {
|
|
|
375
366
|
// Nothing inside may shrink, or the previews get cropped to make room for
|
|
376
367
|
// the textarea instead of scrolling with it.
|
|
377
368
|
expect(card).not.toHaveClass('min-h-0')
|
|
378
|
-
expect(
|
|
379
|
-
|
|
380
|
-
).not.toHaveClass('
|
|
381
|
-
expect(card.querySelector('.messaging-composer-row')).not.toHaveClass(
|
|
382
|
-
'shrink-0'
|
|
383
|
-
)
|
|
369
|
+
expect(screen.getByTestId('composer-previews')).not.toHaveClass('min-h-0')
|
|
370
|
+
// eslint-disable-next-line local/no-style-only-assertion -- MES-1037: the row not being shrink-0 is part of the single-scroll contract in this component's doc comment, and jsdom has no layout to observe the squeeze it prevents.
|
|
371
|
+
expect(screen.getByTestId('composer-row')).not.toHaveClass('shrink-0')
|
|
384
372
|
})
|
|
385
373
|
|
|
386
374
|
it('lets the textarea grow to fit rather than scrolling its own overflow', () => {
|
|
387
|
-
const card =
|
|
375
|
+
const card = mountCard()
|
|
388
376
|
|
|
389
377
|
// TextareaComposer defaults an absent maxRows to 1, so this has to be set.
|
|
390
378
|
expect(screen.getByTestId('textarea-composer')).toHaveAttribute(
|
|
@@ -403,7 +391,7 @@ describe('CustomMessageInput', () => {
|
|
|
403
391
|
})
|
|
404
392
|
|
|
405
393
|
it('leaves plain-text pastes to the browser but lets file pastes reach Stream', () => {
|
|
406
|
-
|
|
394
|
+
mountCard()
|
|
407
395
|
|
|
408
396
|
// Stream's paste handler is dispatched from React's root, above this
|
|
409
397
|
// card, so a listener further up stands in for it: the event reaching
|
|
@@ -428,7 +416,7 @@ describe('CustomMessageInput', () => {
|
|
|
428
416
|
})
|
|
429
417
|
|
|
430
418
|
it('detects a pasted file via clipboardData.items even when .files is empty (Safari)', () => {
|
|
431
|
-
|
|
419
|
+
mountCard()
|
|
432
420
|
|
|
433
421
|
// Safari never populates clipboardData.files on paste, even for an
|
|
434
422
|
// image — only .items has it. Checking .files here would silently drop
|
|
@@ -448,7 +436,7 @@ describe('CustomMessageInput', () => {
|
|
|
448
436
|
})
|
|
449
437
|
|
|
450
438
|
it('scrolls the input back into view when the card shrinks under it', () => {
|
|
451
|
-
const card =
|
|
439
|
+
const card = mountCard()
|
|
452
440
|
// jsdom has no layout, so stand in for a card whose cap now cuts the
|
|
453
441
|
// input off below the fold.
|
|
454
442
|
Object.defineProperty(card, 'scrollHeight', {
|
|
@@ -463,7 +451,7 @@ describe('CustomMessageInput', () => {
|
|
|
463
451
|
})
|
|
464
452
|
|
|
465
453
|
it('leaves the scroll position alone when nothing in the composer is focused', () => {
|
|
466
|
-
const card =
|
|
454
|
+
const card = mountCard()
|
|
467
455
|
Object.defineProperty(card, 'scrollHeight', {
|
|
468
456
|
configurable: true,
|
|
469
457
|
value: 380,
|
|
@@ -478,7 +466,7 @@ describe('CustomMessageInput', () => {
|
|
|
478
466
|
})
|
|
479
467
|
|
|
480
468
|
it('sticks the send button to the bottom of the scroll, at its resting inset', () => {
|
|
481
|
-
|
|
469
|
+
mountCard()
|
|
482
470
|
|
|
483
471
|
// The offset must match the wrapper's own margin: sticky resolves against
|
|
484
472
|
// the border box, so at bottom-0 the button would lose that inset and sit
|
|
@@ -487,7 +475,8 @@ describe('CustomMessageInput', () => {
|
|
|
487
475
|
// card scrolls far enough for the button to ride up to the top of the
|
|
488
476
|
// row, a staged preview otherwise meets it flush. Asserted together so
|
|
489
477
|
// they cannot drift apart.
|
|
490
|
-
|
|
478
|
+
// eslint-disable-next-line local/no-style-only-assertion -- the point is that these three offsets match; sticky resolution isn't observable in jsdom.
|
|
479
|
+
expect(screen.getByTestId('send-button-wrapper')).toHaveClass(
|
|
491
480
|
'sticky',
|
|
492
481
|
'bottom-2',
|
|
493
482
|
'mb-2',
|
|
@@ -539,10 +528,8 @@ describe('CustomMessageInput', () => {
|
|
|
539
528
|
// Simulate the composer growing/shrinking. The observer only re-pins when
|
|
540
529
|
// the composer's offsetHeight increased; jsdom reports 0, so set it
|
|
541
530
|
// explicitly. Mounts with height 0, so any positive value reads as growth.
|
|
542
|
-
const setComposerHeight = (
|
|
543
|
-
const composer =
|
|
544
|
-
'.messaging-composer-chrome--floating'
|
|
545
|
-
) as HTMLElement
|
|
531
|
+
const setComposerHeight = (height: number) => {
|
|
532
|
+
const composer = screen.getByTestId('composer-chrome')
|
|
546
533
|
Object.defineProperty(composer, 'offsetHeight', {
|
|
547
534
|
value: height,
|
|
548
535
|
configurable: true,
|
|
@@ -572,7 +559,7 @@ describe('CustomMessageInput', () => {
|
|
|
572
559
|
})
|
|
573
560
|
|
|
574
561
|
it('scrolls to the bottom when the composer grows and the user was at the bottom', () => {
|
|
575
|
-
|
|
562
|
+
renderInLayout()
|
|
576
563
|
const scroller = screen.getByTestId('scroller')
|
|
577
564
|
|
|
578
565
|
// At the bottom: scrollHeight - scrollTop - clientHeight = 0.
|
|
@@ -581,7 +568,7 @@ describe('CustomMessageInput', () => {
|
|
|
581
568
|
clientHeight: 300,
|
|
582
569
|
scrollTop: 260,
|
|
583
570
|
})
|
|
584
|
-
setComposerHeight(
|
|
571
|
+
setComposerHeight(200) // grew from 0
|
|
585
572
|
|
|
586
573
|
resizeCallback?.([], {} as ResizeObserver)
|
|
587
574
|
|
|
@@ -589,7 +576,7 @@ describe('CustomMessageInput', () => {
|
|
|
589
576
|
})
|
|
590
577
|
|
|
591
578
|
it('leaves the scroll position alone when the user has scrolled up', () => {
|
|
592
|
-
|
|
579
|
+
renderInLayout()
|
|
593
580
|
const scroller = screen.getByTestId('scroller')
|
|
594
581
|
|
|
595
582
|
// Scrolled up reading history: distance from bottom (600px) is well over
|
|
@@ -599,7 +586,7 @@ describe('CustomMessageInput', () => {
|
|
|
599
586
|
clientHeight: 300,
|
|
600
587
|
scrollTop: 100,
|
|
601
588
|
})
|
|
602
|
-
setComposerHeight(
|
|
589
|
+
setComposerHeight(200) // grew, but user is not near the bottom
|
|
603
590
|
|
|
604
591
|
resizeCallback?.([], {} as ResizeObserver)
|
|
605
592
|
|
|
@@ -184,6 +184,7 @@ const CustomMessageInputInner: React.FC = () => {
|
|
|
184
184
|
// such foreign direct child, without re-adding it to our own wrappers.
|
|
185
185
|
<div
|
|
186
186
|
ref={cardRef}
|
|
187
|
+
data-testid="composer-card"
|
|
187
188
|
className="central-container flex flex-col min-w-0 w-full max-h-[calc(var(--messaging-surface-height,100dvh)*0.5)] overflow-y-auto overscroll-contain bg-white rounded-[1.5rem] shadow-[0_4px_16px_0_rgba(0,0,0,0.08),0_1px_2px_0_rgba(0,0,0,0.04),0_0_0_1px_rgba(0,0,0,0.04)] [&>:not(.messaging-composer-previews):not(.messaging-composer-row)]:p-2"
|
|
188
189
|
data-dd-privacy="mask"
|
|
189
190
|
>
|
|
@@ -193,12 +194,18 @@ const CustomMessageInputInner: React.FC = () => {
|
|
|
193
194
|
textarea's own py-3.5 already separates the last preview from the
|
|
194
195
|
text, and adding one here double-spaces them now that both sit in the
|
|
195
196
|
same scroll. */}
|
|
196
|
-
<div
|
|
197
|
+
<div
|
|
198
|
+
data-testid="composer-previews"
|
|
199
|
+
className="messaging-composer-previews empty:hidden flex flex-col gap-2 px-2 pt-2"
|
|
200
|
+
>
|
|
197
201
|
<QuotedMessagePreview />
|
|
198
202
|
<CustomLinkPreviewList />
|
|
199
203
|
<AttachmentPreviewList />
|
|
200
204
|
</div>
|
|
201
|
-
<div
|
|
205
|
+
<div
|
|
206
|
+
data-testid="composer-row"
|
|
207
|
+
className="messaging-composer-row flex items-start"
|
|
208
|
+
>
|
|
202
209
|
<TextareaComposer
|
|
203
210
|
aria-disabled={disabled || undefined}
|
|
204
211
|
// TextareaComposer wraps the real <textarea> in its own div, so
|
|
@@ -271,7 +278,10 @@ const CustomMessageInputInner: React.FC = () => {
|
|
|
271
278
|
background to sit on: sticky cannot leave its containing block, so
|
|
272
279
|
the button stays inside the row and a preview never passes behind
|
|
273
280
|
it. */}
|
|
274
|
-
<div
|
|
281
|
+
<div
|
|
282
|
+
data-testid="send-button-wrapper"
|
|
283
|
+
className="sticky bottom-2 mt-auto mb-2 mr-2 shrink-0 pt-2"
|
|
284
|
+
>
|
|
275
285
|
<SendButton
|
|
276
286
|
sendMessage={handleSubmit}
|
|
277
287
|
aria-label="Send"
|
|
@@ -430,7 +440,10 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
|
430
440
|
return (
|
|
431
441
|
<>
|
|
432
442
|
{renderHeader?.()}
|
|
433
|
-
<div
|
|
443
|
+
<div
|
|
444
|
+
data-testid="composer-locked-panel"
|
|
445
|
+
className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4"
|
|
446
|
+
>
|
|
434
447
|
{disabledReason ? (
|
|
435
448
|
<p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
|
|
436
449
|
{disabledReason}
|
|
@@ -445,12 +458,16 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
|
445
458
|
return (
|
|
446
459
|
<div
|
|
447
460
|
ref={composerRef}
|
|
461
|
+
data-testid="composer-chrome"
|
|
448
462
|
className="messaging-composer-chrome messaging-composer-chrome--floating"
|
|
449
463
|
>
|
|
450
464
|
<ComposerBackdrop />
|
|
451
465
|
<div className="relative z-10 flex flex-col">
|
|
452
466
|
{renderHeader?.()}
|
|
453
|
-
<div
|
|
467
|
+
<div
|
|
468
|
+
data-testid="composer-locked-panel"
|
|
469
|
+
className="messaging-composer-locked-panel flex w-full flex-col items-center justify-center gap-3 px-6 py-4"
|
|
470
|
+
>
|
|
454
471
|
{disabledReason ? (
|
|
455
472
|
<p className="max-w-[345px] text-center text-xs font-normal leading-[1.3] tracking-[0.12px] text-black/40">
|
|
456
473
|
{disabledReason}
|
|
@@ -471,6 +488,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
|
471
488
|
// @ts-expect-error Only React 19 onwards has `inert` in its types.
|
|
472
489
|
inert={isFrozen ? '' : undefined}
|
|
473
490
|
aria-disabled={isFrozen || undefined}
|
|
491
|
+
data-testid="message-input"
|
|
474
492
|
className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
|
|
475
493
|
>
|
|
476
494
|
{renderActions && (
|
|
@@ -490,6 +508,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
|
490
508
|
return (
|
|
491
509
|
<div
|
|
492
510
|
ref={composerRef}
|
|
511
|
+
data-testid="composer-chrome"
|
|
493
512
|
className="messaging-composer-chrome messaging-composer-chrome--floating"
|
|
494
513
|
>
|
|
495
514
|
<ComposerBackdrop />
|
|
@@ -501,6 +520,7 @@ export const CustomMessageInput: React.FC<CustomMessageInputProps> = ({
|
|
|
501
520
|
// @ts-expect-error Only React 19 onwards has `inert` in its types.
|
|
502
521
|
inert={isFrozen ? '' : undefined}
|
|
503
522
|
aria-disabled={isFrozen || undefined}
|
|
523
|
+
data-testid="message-input"
|
|
504
524
|
className="message-input flex min-h-0 items-end gap-4 aria-disabled:opacity-40"
|
|
505
525
|
>
|
|
506
526
|
{renderActions && (
|
|
@@ -19,13 +19,17 @@ const createProps = (
|
|
|
19
19
|
},
|
|
20
20
|
}) as EventComponentProps
|
|
21
21
|
|
|
22
|
+
// Clock digits, not a locale-formatted string: the contract is that a
|
|
23
|
+
// timestamp shows, not how it is formatted.
|
|
24
|
+
const TIMESTAMP = /\d{1,2}:\d{2}/
|
|
25
|
+
|
|
22
26
|
describe('CustomSystemMessage', () => {
|
|
23
27
|
it.each([
|
|
24
28
|
'SYSTEM_ESCALATION',
|
|
25
29
|
'SYSTEM_DM_AGENT_PAUSED',
|
|
26
30
|
'SYSTEM_DM_AGENT_RESUMED',
|
|
27
31
|
] as const)('hides the timestamp for %s', (customType) => {
|
|
28
|
-
|
|
32
|
+
renderWithProviders(
|
|
29
33
|
<CustomSystemMessage
|
|
30
34
|
{...createProps({
|
|
31
35
|
metadata: { custom_type: customType },
|
|
@@ -33,11 +37,11 @@ describe('CustomSystemMessage', () => {
|
|
|
33
37
|
/>
|
|
34
38
|
)
|
|
35
39
|
|
|
36
|
-
expect(
|
|
40
|
+
expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
|
|
37
41
|
})
|
|
38
42
|
|
|
39
43
|
it('shows the timestamp for age safety system messages', () => {
|
|
40
|
-
|
|
44
|
+
renderWithProviders(
|
|
41
45
|
<CustomSystemMessage
|
|
42
46
|
{...createProps({
|
|
43
47
|
metadata: { custom_type: 'SYSTEM_AGE_SAFETY_BLOCKED' },
|
|
@@ -45,22 +49,20 @@ describe('CustomSystemMessage', () => {
|
|
|
45
49
|
/>
|
|
46
50
|
)
|
|
47
51
|
|
|
48
|
-
expect(
|
|
52
|
+
expect(screen.getByText(TIMESTAMP)).toBeInTheDocument()
|
|
49
53
|
})
|
|
50
54
|
|
|
51
55
|
it.each([
|
|
52
56
|
{ metadata: {}, text: 'Unknown system message' },
|
|
53
57
|
{ text: 'Absent system type' },
|
|
54
58
|
])('hides the timestamp for unknown or absent system types', (overrides) => {
|
|
55
|
-
|
|
56
|
-
<CustomSystemMessage {...createProps(overrides)} />
|
|
57
|
-
)
|
|
59
|
+
renderWithProviders(<CustomSystemMessage {...createProps(overrides)} />)
|
|
58
60
|
|
|
59
|
-
expect(
|
|
61
|
+
expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
|
|
60
62
|
})
|
|
61
63
|
|
|
62
64
|
it('hides the timestamp for legacy dm-agent messages without a custom_type', () => {
|
|
63
|
-
|
|
65
|
+
renderWithProviders(
|
|
64
66
|
<CustomSystemMessage
|
|
65
67
|
{...createProps({
|
|
66
68
|
dm_agent_system_type: 'SYSTEM_DM_AGENT_PAUSED',
|
|
@@ -68,7 +70,7 @@ describe('CustomSystemMessage', () => {
|
|
|
68
70
|
/>
|
|
69
71
|
)
|
|
70
72
|
|
|
71
|
-
expect(
|
|
73
|
+
expect(screen.queryByText(TIMESTAMP)).not.toBeInTheDocument()
|
|
72
74
|
})
|
|
73
75
|
|
|
74
76
|
it('detects DM Agent system subtype from metadata.custom_type', () => {
|
|
@@ -112,7 +114,7 @@ describe('CustomSystemMessage', () => {
|
|
|
112
114
|
})
|
|
113
115
|
|
|
114
116
|
it('renders the age safety blocked subtype with fallback copy and warning treatment', () => {
|
|
115
|
-
|
|
117
|
+
renderWithProviders(
|
|
116
118
|
<CustomSystemMessage
|
|
117
119
|
{...createProps({
|
|
118
120
|
text: ' ',
|
|
@@ -146,18 +148,10 @@ describe('CustomSystemMessage', () => {
|
|
|
146
148
|
expect(
|
|
147
149
|
screen.getByTestId('age-safety-system-message-icon')
|
|
148
150
|
).toBeInTheDocument()
|
|
149
|
-
expect(
|
|
150
|
-
container.querySelector('.mes-age-safety-system-message__icon')
|
|
151
|
-
).toBeInTheDocument()
|
|
152
|
-
expect(
|
|
153
|
-
container.querySelector(
|
|
154
|
-
'[data-testid="age-safety-system-message-icon"] path[opacity="0.2"]'
|
|
155
|
-
)
|
|
156
|
-
).toBeInTheDocument()
|
|
157
151
|
})
|
|
158
152
|
|
|
159
153
|
it('falls back to legacy generic rendering when subtype is absent or unknown', () => {
|
|
160
|
-
|
|
154
|
+
renderWithProviders(
|
|
161
155
|
<CustomSystemMessage
|
|
162
156
|
{...createProps({
|
|
163
157
|
text: 'Legacy system message',
|
|
@@ -170,9 +164,9 @@ describe('CustomSystemMessage', () => {
|
|
|
170
164
|
expect(
|
|
171
165
|
screen.queryByTestId('dm-agent-system-message')
|
|
172
166
|
).not.toBeInTheDocument()
|
|
173
|
-
expect(screen.getByText('Legacy system message')).toBeInTheDocument()
|
|
174
167
|
expect(
|
|
175
|
-
|
|
176
|
-
).toBeInTheDocument()
|
|
168
|
+
screen.queryByTestId('age-safety-system-message')
|
|
169
|
+
).not.toBeInTheDocument()
|
|
170
|
+
expect(screen.getByText('Legacy system message')).toBeInTheDocument()
|
|
177
171
|
})
|
|
178
172
|
})
|
|
@@ -129,11 +129,10 @@ describe('FaqList', () => {
|
|
|
129
129
|
})
|
|
130
130
|
|
|
131
131
|
it('renders the header only when headerText is provided', () => {
|
|
132
|
-
const {
|
|
132
|
+
const { rerender } = renderWithProviders(
|
|
133
133
|
<FaqList faqs={faqs} onFaqClick={vi.fn()} />
|
|
134
134
|
)
|
|
135
|
-
expect(screen.
|
|
136
|
-
expect(container.querySelector('p')).toBeNull()
|
|
135
|
+
expect(screen.queryByTestId('faq-list-header')).not.toBeInTheDocument()
|
|
137
136
|
|
|
138
137
|
rerender(
|
|
139
138
|
<FaqList
|
|
@@ -142,7 +141,9 @@ describe('FaqList', () => {
|
|
|
142
141
|
headerText="Frequently asked questions"
|
|
143
142
|
/>
|
|
144
143
|
)
|
|
145
|
-
expect(screen.
|
|
144
|
+
expect(screen.getByTestId('faq-list-header')).toHaveTextContent(
|
|
145
|
+
'Frequently asked questions'
|
|
146
|
+
)
|
|
146
147
|
})
|
|
147
148
|
|
|
148
149
|
it('passes className to the list root', () => {
|
|
@@ -60,7 +60,12 @@ export const FaqList: React.FC<FaqListProps> = ({
|
|
|
60
60
|
style={{ backgroundColor: '#F1F0EE' }}
|
|
61
61
|
>
|
|
62
62
|
{headerText && (
|
|
63
|
-
<p
|
|
63
|
+
<p
|
|
64
|
+
data-testid="faq-list-header"
|
|
65
|
+
className="text-md text-charcoal mb-4"
|
|
66
|
+
>
|
|
67
|
+
{headerText}
|
|
68
|
+
</p>
|
|
64
69
|
)}
|
|
65
70
|
{enabledFaqs.map((faq) => (
|
|
66
71
|
<FaqListItem
|