@linktr.ee/messaging-react 4.4.6 → 4.4.7-rc-1788243598

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "4.4.6",
3
+ "version": "4.4.7-rc-1788243598",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.cjs",
@@ -50,8 +50,8 @@
50
50
  "chromatic:local": "yarn storybook:build && chromatic"
51
51
  },
52
52
  "dependencies": {
53
- "@linktr.ee/messaging-core": "^2.6.0",
54
- "@linktr.ee/messaging-taxonomy": "^0.11.0",
53
+ "@linktr.ee/messaging-core": "2.6.0-rc-1788243598",
54
+ "@linktr.ee/messaging-taxonomy": "0.11.0-rc-1788243598",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
57
57
  "devDependencies": {
@@ -24,7 +24,9 @@ vi.mock('stream-chat-react', () => ({
24
24
  }: {
25
25
  MessageSentStatus: React.ComponentType
26
26
  }) => <MessageSentStatus />,
27
- MessageText: () => <span>Message</span>,
27
+ MessageText: ({ message }: { message?: { text?: string } }) => (
28
+ <span>{message?.text ?? 'Message'}</span>
29
+ ),
28
30
  Poll: () => null,
29
31
  ReminderNotification: () => null,
30
32
  StreamedMessageText: () => null,
@@ -54,13 +56,24 @@ vi.mock('../Avatar', () => ({
54
56
  Avatar: () => null,
55
57
  }))
56
58
 
59
+ vi.mock('../LinkAttachment', () => ({
60
+ default: {
61
+ Sent: ({ title }: { title?: string }) => <span>{title}</span>,
62
+ Received: ({ title }: { title?: string }) => <span>{title}</span>,
63
+ },
64
+ }))
65
+
57
66
  vi.mock('./MessageTag', () => ({
58
67
  MessageTag: () => null,
59
68
  isAttachmentMessage: () => false,
60
69
  isChatbotMessage: () => false,
61
- isMediaAttachmentMessage: () => false,
70
+ isMediaAttachmentMessage: (message: {
71
+ metadata?: { attachment_content_type?: string }
72
+ }) => message.metadata?.attachment_content_type === 'media',
62
73
  isPaidMessage: () => false,
63
- isTextAttachmentMessage: () => false,
74
+ isTextAttachmentMessage: (message: {
75
+ metadata?: { attachment_content_type?: string }
76
+ }) => message.metadata?.attachment_content_type === 'text',
64
77
  hasPaidDeliveryTag: () => false,
65
78
  }))
66
79
 
@@ -70,16 +83,32 @@ vi.mock('./TipMessage', () => ({
70
83
  }))
71
84
 
72
85
  vi.mock('./LockedAttachment', () => ({
73
- default: () => null,
86
+ default: () => <span>Locked attachment</span>,
74
87
  }))
75
88
 
76
89
  vi.mock('./StreamAttachmentMessage', () => ({
77
90
  default: () => null,
78
91
  buildOrderedAttachmentSegments: () => [],
92
+ linkCardPropsFromStreamAttachment: (attachment: {
93
+ title?: string
94
+ og_scrape_url?: string
95
+ }) => ({
96
+ title: attachment.title,
97
+ url: attachment.og_scrape_url,
98
+ }),
99
+ linkCaptionDuplicatesAttachmentUrl: (
100
+ caption?: string,
101
+ attachmentUrl?: string
102
+ ) =>
103
+ !!caption &&
104
+ !!attachmentUrl &&
105
+ caption.trim().replace(/\/+$/, '').toLowerCase() ===
106
+ attachmentUrl.trim().replace(/\/+$/, '').toLowerCase(),
79
107
  }))
80
108
 
81
109
  vi.mock('../MediaMessage', () => ({
82
- isLinkAttachment: () => false,
110
+ isLinkAttachment: (attachment: { type?: string; og_scrape_url?: string }) =>
111
+ attachment.type === 'link' || !!attachment.og_scrape_url?.trim(),
83
112
  }))
84
113
 
85
114
  const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
@@ -259,6 +288,226 @@ describe('CustomMessage', () => {
259
288
  </OutboundContext.Provider>
260
289
  )
261
290
 
262
- expect(screen.getByText('Message')).toBeInTheDocument()
291
+ expect(screen.getByText('Hello')).toBeInTheDocument()
292
+ })
293
+
294
+ it('renders a link attachment alongside a locked media attachment', () => {
295
+ mockedUseMessageContext.mockReturnValue({
296
+ message: {
297
+ id: 'locked-media-message',
298
+ text: '',
299
+ type: 'regular',
300
+ status: 'received',
301
+ user: { id: 'me', name: 'Me' },
302
+ metadata: {
303
+ custom_type: 'MESSAGE_ATTACHMENT',
304
+ attachment_content_type: 'media',
305
+ },
306
+ attachments: [
307
+ {
308
+ type: 'link',
309
+ og_scrape_url: 'https://reactjs.org/',
310
+ title: 'ReactJS Philippines',
311
+ },
312
+ ],
313
+ },
314
+ isMyMessage: () => true,
315
+ threadList: false,
316
+ editing: false,
317
+ endOfGroup: true,
318
+ firstOfGroup: true,
319
+ groupedByUser: true,
320
+ handleAction: vi.fn(),
321
+ handleOpenThread: vi.fn(),
322
+ handleRetry: vi.fn(),
323
+ highlighted: false,
324
+ renderText: undefined,
325
+ isMessageAIGenerated: undefined,
326
+ } as never)
327
+
328
+ renderWithProviders(
329
+ <OutboundContext.Provider value={{}}>
330
+ <CustomMessage />
331
+ </OutboundContext.Provider>
332
+ )
333
+
334
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
335
+ expect(screen.getByText('Locked attachment')).toBeInTheDocument()
336
+ })
337
+
338
+ it('renders a link attachment alongside a locked text attachment', () => {
339
+ mockedUseMessageContext.mockReturnValue({
340
+ message: {
341
+ id: 'locked-text-message',
342
+ text: '',
343
+ type: 'regular',
344
+ status: 'received',
345
+ user: { id: 'me', name: 'Me' },
346
+ metadata: {
347
+ custom_type: 'MESSAGE_ATTACHMENT',
348
+ attachment_content_type: 'text',
349
+ },
350
+ attachments: [
351
+ {
352
+ type: 'link',
353
+ og_scrape_url: 'https://reactjs.org/',
354
+ title: 'ReactJS Philippines',
355
+ },
356
+ ],
357
+ },
358
+ isMyMessage: () => true,
359
+ threadList: false,
360
+ editing: false,
361
+ endOfGroup: true,
362
+ firstOfGroup: true,
363
+ groupedByUser: true,
364
+ handleAction: vi.fn(),
365
+ handleOpenThread: vi.fn(),
366
+ handleRetry: vi.fn(),
367
+ highlighted: false,
368
+ renderText: undefined,
369
+ isMessageAIGenerated: undefined,
370
+ } as never)
371
+
372
+ renderWithProviders(
373
+ <OutboundContext.Provider value={{}}>
374
+ <CustomMessage />
375
+ </OutboundContext.Provider>
376
+ )
377
+
378
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
379
+ expect(screen.getByText('Locked attachment')).toBeInTheDocument()
380
+ })
381
+
382
+ it('does not render a link card when a locked message has no link attachments', () => {
383
+ mockedUseMessageContext.mockReturnValue({
384
+ message: {
385
+ id: 'locked-media-without-link',
386
+ text: '',
387
+ type: 'regular',
388
+ status: 'received',
389
+ user: { id: 'me', name: 'Me' },
390
+ metadata: {
391
+ custom_type: 'MESSAGE_ATTACHMENT',
392
+ attachment_content_type: 'media',
393
+ },
394
+ attachments: [
395
+ {
396
+ type: 'image',
397
+ image_url: 'https://cdn.example/image.jpg',
398
+ },
399
+ ],
400
+ },
401
+ isMyMessage: () => true,
402
+ threadList: false,
403
+ editing: false,
404
+ endOfGroup: true,
405
+ firstOfGroup: true,
406
+ groupedByUser: true,
407
+ handleAction: vi.fn(),
408
+ handleOpenThread: vi.fn(),
409
+ handleRetry: vi.fn(),
410
+ highlighted: false,
411
+ renderText: undefined,
412
+ isMessageAIGenerated: undefined,
413
+ } as never)
414
+
415
+ renderWithProviders(
416
+ <OutboundContext.Provider value={{}}>
417
+ <CustomMessage />
418
+ </OutboundContext.Provider>
419
+ )
420
+
421
+ expect(screen.queryByText(/reactjs philippines/i)).not.toBeInTheDocument()
422
+ })
423
+
424
+ it('does not render a duplicate URL caption for a locked media attachment', () => {
425
+ const linkUrl = 'https://reactjs.org/'
426
+ mockedUseMessageContext.mockReturnValue({
427
+ message: {
428
+ id: 'locked-media-url-caption',
429
+ text: linkUrl,
430
+ type: 'regular',
431
+ status: 'received',
432
+ user: { id: 'me', name: 'Me' },
433
+ metadata: {
434
+ custom_type: 'MESSAGE_ATTACHMENT',
435
+ attachment_content_type: 'media',
436
+ },
437
+ attachments: [
438
+ {
439
+ type: 'link',
440
+ og_scrape_url: linkUrl,
441
+ title: 'ReactJS Philippines',
442
+ },
443
+ ],
444
+ },
445
+ isMyMessage: () => true,
446
+ threadList: false,
447
+ editing: false,
448
+ endOfGroup: true,
449
+ firstOfGroup: true,
450
+ groupedByUser: true,
451
+ handleAction: vi.fn(),
452
+ handleOpenThread: vi.fn(),
453
+ handleRetry: vi.fn(),
454
+ highlighted: false,
455
+ renderText: undefined,
456
+ isMessageAIGenerated: undefined,
457
+ } as never)
458
+
459
+ renderWithProviders(
460
+ <OutboundContext.Provider value={{}}>
461
+ <CustomMessage />
462
+ </OutboundContext.Provider>
463
+ )
464
+
465
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
466
+ expect(screen.queryByText(linkUrl)).not.toBeInTheDocument()
467
+ })
468
+
469
+ it('renders a genuine caption alongside a locked media attachment', () => {
470
+ const linkUrl = 'https://reactjs.org/'
471
+ mockedUseMessageContext.mockReturnValue({
472
+ message: {
473
+ id: 'locked-media-genuine-caption',
474
+ text: 'Check this out',
475
+ type: 'regular',
476
+ status: 'received',
477
+ user: { id: 'me', name: 'Me' },
478
+ metadata: {
479
+ custom_type: 'MESSAGE_ATTACHMENT',
480
+ attachment_content_type: 'media',
481
+ },
482
+ attachments: [
483
+ {
484
+ type: 'link',
485
+ og_scrape_url: linkUrl,
486
+ title: 'ReactJS Philippines',
487
+ },
488
+ ],
489
+ },
490
+ isMyMessage: () => true,
491
+ threadList: false,
492
+ editing: false,
493
+ endOfGroup: true,
494
+ firstOfGroup: true,
495
+ groupedByUser: true,
496
+ handleAction: vi.fn(),
497
+ handleOpenThread: vi.fn(),
498
+ handleRetry: vi.fn(),
499
+ highlighted: false,
500
+ renderText: undefined,
501
+ isMessageAIGenerated: undefined,
502
+ } as never)
503
+
504
+ renderWithProviders(
505
+ <OutboundContext.Provider value={{}}>
506
+ <CustomMessage />
507
+ </OutboundContext.Provider>
508
+ )
509
+
510
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
511
+ expect(screen.getByText(/check this out/i)).toBeInTheDocument()
263
512
  })
264
513
  })
@@ -32,6 +32,7 @@ import {
32
32
  import type { OutboundClickHandler, ResolvedOutbound } from '../../types'
33
33
  import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
34
34
  import { Avatar } from '../Avatar'
35
+ import LinkAttachment from '../LinkAttachment'
35
36
  import { isLinkAttachment } from '../MediaMessage'
36
37
  import type { BubbleGroupPosition } from '../MessageAttachment/types'
37
38
 
@@ -49,6 +50,8 @@ import { OutboundContext } from './OutboundContext'
49
50
  import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
50
51
  import StreamAttachmentMessage, {
51
52
  buildOrderedAttachmentSegments,
53
+ linkCardPropsFromStreamAttachment,
54
+ linkCaptionDuplicatesAttachmentUrl,
52
55
  } from './StreamAttachmentMessage'
53
56
  import { TipMessage, isTipMessage } from './TipMessage'
54
57
 
@@ -123,6 +126,20 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
123
126
  const filtered = raw.filter((a) => !('type' in a) || !isLinkAttachment(a))
124
127
  return filtered.length === raw.length ? raw : filtered
125
128
  }, [message])
129
+ const lockedLinkAttachments = useMemo(
130
+ () => (message.attachments ?? []).filter(isLinkAttachment),
131
+ [message]
132
+ )
133
+ const lockedCaption = useMemo(() => {
134
+ const raw = message.text?.trim()
135
+ if (!raw) return undefined
136
+ const primaryLinkUrl = lockedLinkAttachments[0]
137
+ ? linkCardPropsFromStreamAttachment(lockedLinkAttachments[0]).url
138
+ : undefined
139
+ return linkCaptionDuplicatesAttachmentUrl(raw, primaryLinkUrl)
140
+ ? undefined
141
+ : raw
142
+ }, [message, lockedLinkAttachments])
126
143
  const attachmentSegments = useMemo(
127
144
  () => buildOrderedAttachmentSegments(finalAttachments),
128
145
  [finalAttachments]
@@ -270,6 +287,27 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
270
287
  {showTail && <MessageTail side={isMine ? 'me' : 'other'} />}
271
288
  </div>
272
289
  )
290
+ const lockedLinkCards = lockedLinkAttachments.length > 0 && (
291
+ <div
292
+ className={classNames(
293
+ 'flex flex-col gap-[4px]',
294
+ isMine ? 'items-end' : 'items-start'
295
+ )}
296
+ >
297
+ {lockedLinkAttachments.map((attachment, index) => {
298
+ const Card = isMine ? LinkAttachment.Sent : LinkAttachment.Received
299
+ const isFinalRow =
300
+ index === lockedLinkAttachments.length - 1 && !lockedCaption
301
+ return (
302
+ <Card
303
+ key={`locked-link-${index}`}
304
+ {...linkCardPropsFromStreamAttachment(attachment)}
305
+ groupPosition={isFinalRow ? bubbleGroupPosition : 'middle'}
306
+ />
307
+ )
308
+ })}
309
+ </div>
310
+ )
273
311
  return (
274
312
  <>
275
313
  {editing && (
@@ -340,6 +378,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
340
378
  )}
341
379
  {!isMine && MessageActions && <MessageActions />}
342
380
  </div>
381
+ {lockedLinkCards}
343
382
  </div>
344
383
  ) : isMediaAttachmentMessage(message) ? (
345
384
  <div className="str-chat__message-bubble-wrapper message-locked-attachment-wrapper">
@@ -374,7 +413,8 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
374
413
  )}
375
414
  {!isMine && MessageActions && <MessageActions />}
376
415
  </div>
377
- {message.text && accompanyingTextBubble}
416
+ {lockedLinkCards}
417
+ {lockedCaption && accompanyingTextBubble}
378
418
  </div>
379
419
  ) : canRenderAttachmentsInToolkit ? (
380
420
  <StreamAttachmentMessage
@@ -105,7 +105,7 @@ describe('MessageAttachment behavior', () => {
105
105
  )
106
106
  })
107
107
 
108
- it('renders video poster and fallback, and wires viewer attributes', () => {
108
+ it('renders video posters and posterless first-frame previews', () => {
109
109
  const onDismiss = vi.fn()
110
110
  const { rerender } = renderWithProviders(
111
111
  <MessageAttachment.Video.Composer
@@ -121,12 +121,55 @@ describe('MessageAttachment behavior', () => {
121
121
  ).toHaveAttribute('src', '/poster.jpg')
122
122
  fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
123
123
  expect(onDismiss).toHaveBeenCalledOnce()
124
+ rerender(
125
+ <MessageAttachment.Video.Sent src="https://cdn.example.com/video.mp4" />
126
+ )
127
+ expect(screen.getByLabelText('Video 1 thumbnail')).toHaveAttribute(
128
+ 'src',
129
+ 'https://cdn.example.com/video.mp4#t=0.001'
130
+ )
131
+ expect(screen.getByLabelText('Play video 1 of 1')).toBeInTheDocument()
132
+
133
+ rerender(
134
+ <MessageAttachment.Video.Sent src="https://cdn.example.com/video.mp4#track=fr" />
135
+ )
136
+ expect(screen.getByLabelText('Video 1 thumbnail')).toHaveAttribute(
137
+ 'src',
138
+ 'https://cdn.example.com/video.mp4#track=fr&t=0.001'
139
+ )
140
+
141
+ rerender(
142
+ <MessageAttachment.Video.Sent src="https://cdn.example.com/video.mp4#t=5" />
143
+ )
144
+ expect(screen.getByLabelText('Video 1 thumbnail')).toHaveAttribute(
145
+ 'src',
146
+ 'https://cdn.example.com/video.mp4#t=5'
147
+ )
148
+
149
+ rerender(
150
+ <MessageAttachment.Video.Received src="https://cdn.example.com/received-video.mp4" />
151
+ )
152
+ expect(screen.getByLabelText('Video 1 thumbnail')).toHaveAttribute(
153
+ 'src',
154
+ 'https://cdn.example.com/received-video.mp4#t=0.001'
155
+ )
156
+
124
157
  rerender(
125
158
  <MessageAttachment.Video.Sent
126
- items={[{ src: 'https://cdn.example.com/video.mp4' }]}
159
+ src="https://cdn.example.com/video.mp4"
160
+ preload="none"
127
161
  />
128
162
  )
163
+ expect(screen.queryByLabelText('Video 1 thumbnail')).not.toBeInTheDocument()
129
164
  expect(screen.getByLabelText('Play video 1 of 1')).toBeInTheDocument()
165
+
166
+ rerender(
167
+ <MessageAttachment.Video.Composer
168
+ src="https://cdn.example.com/video.mp4"
169
+ preload="none"
170
+ />
171
+ )
172
+ expect(screen.queryByLabelText('Video 1 thumbnail')).not.toBeInTheDocument()
130
173
  })
131
174
 
132
175
  it('supports PDF titles, download actions, dismiss, and viewer index', () => {
@@ -42,10 +42,10 @@ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
42
42
  items?: VideoItem[]
43
43
  /**
44
44
  * `<video preload>` hint forwarded into the viewer. Defaults to
45
- * `'none'` — the poster `<img>` carries the visual weight on the
46
- * bubble surface, and we shouldn't fetch any video bytes until the
47
- * user actually opens the viewer. Per-item overrides live on
48
- * `VideoItem.preload`. The opened `VideoViewer` always preloads
45
+ * `'none'` — poster images carry the visual weight on the bubble surface,
46
+ * while posterless tiles fetch metadata to paint a first frame unless
47
+ * `'none'` is explicitly set, in which case they use the placeholder icon.
48
+ * Per-item overrides live on `VideoItem.preload`. The opened `VideoViewer` always preloads
49
49
  * metadata for the active item (so duration / first-frame appear
50
50
  * immediately) regardless of this value.
51
51
  */
@@ -70,9 +70,8 @@ export interface VideoAttachmentSharedProps extends MessageAttachmentBaseProps {
70
70
  * recorded it, and only otherwise measured off the decoded poster. Measuring
71
71
  * the poster alone was not enough: an uploaded clip may have no poster, and a
72
72
  * portrait video then sat in whatever `FALLBACK_ASPECT` guessed. Reading the
73
- * dimensions Stream already stores costs nothing at render time and needs no
74
- * `loadedmetadata` probe, which would fetch video bytes for every card in the
75
- * thread against the deliberate `preload: 'none'`.
73
+ * dimensions Stream already stores costs nothing at render time; posterless
74
+ * tiles fetch metadata to paint a first frame, without using it for sizing.
76
75
  *
77
76
  * `FALLBACK_ASPECT` is **square** on purpose. It covers only the case where we
78
77
  * know nothing — no recorded dimensions, no decoded poster — and a square box
@@ -101,6 +100,14 @@ const PlayBadge: React.FC = () => (
101
100
  </div>
102
101
  )
103
102
 
103
+ // Safari needs a time fragment to paint a first frame from a remote MP4.
104
+ const withFirstFrameHint = (src: string): string => {
105
+ const hashIndex = src.indexOf('#')
106
+ if (hashIndex === -1) return `${src}#t=0.001`
107
+ const fragment = src.slice(hashIndex + 1)
108
+ return /(^|&)t=/.test(fragment) ? src : `${src}&t=0.001`
109
+ }
110
+
104
111
  // The tile shell owns the cluster's geometry: `MediaStackGrid` gives
105
112
  // each tile the 18px radius on the cluster's OUTER corners only and
106
113
  // clips with `overflow-hidden`, so a poster must carry no radius of
@@ -137,6 +144,15 @@ const PosterTile: React.FC<{
137
144
  onLoad={onLoad}
138
145
  className="absolute inset-0 size-full object-cover"
139
146
  />
147
+ ) : item.src && item.preload !== 'none' ? (
148
+ <video
149
+ src={withFirstFrameHint(item.src)}
150
+ muted
151
+ playsInline
152
+ preload="metadata"
153
+ aria-label={`Video ${index + 1} thumbnail`}
154
+ className="absolute inset-0 size-full object-cover"
155
+ />
140
156
  ) : (
141
157
  <div className="absolute inset-0 flex items-center justify-center">
142
158
  <VideoCameraIcon
@@ -264,7 +280,7 @@ const VideoComposerInner: React.FC<{
264
280
  aria-label="Play video"
265
281
  className="relative block size-[280px] cursor-pointer overflow-hidden rounded-md outline-none focus-visible:ring-2 focus-visible:ring-black/40"
266
282
  >
267
- <PosterTile item={{ src, poster, mimeType }} index={0} />
283
+ <PosterTile item={{ src, poster, mimeType, preload }} index={0} />
268
284
  </button>
269
285
  {onDismiss ? (
270
286
  <div className="absolute right-2 top-2 z-10">
@@ -22,7 +22,7 @@ const ErrorEmitter: React.FC<{ error: Error }> = ({ error }) => {
22
22
  /** Emits one line at every level so partial-sink fallback is observable. */
23
23
  const AllLevelsEmitter: React.FC = () => {
24
24
  const logger = useMessagingLogger()
25
- // eslint-disable-next-line testing-library/no-debugging-utils -- this is the messaging logger's debug level, not screen.debug()
25
+ // This is the messaging logger's debug level, not screen.debug().
26
26
  logger.debug('debug line', 1)
27
27
  logger.info('info line', 2)
28
28
  logger.warn('warn line', 3)