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

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-1788242278",
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-1788242278",
54
+ "@linktr.ee/messaging-taxonomy": "0.11.0-rc-1788242278",
55
55
  "@phosphor-icons/react": "^2.1.10"
56
56
  },
57
57
  "devDependencies": {
@@ -54,13 +54,24 @@ vi.mock('../Avatar', () => ({
54
54
  Avatar: () => null,
55
55
  }))
56
56
 
57
+ vi.mock('../LinkAttachment', () => ({
58
+ default: {
59
+ Sent: ({ title }: { title?: string }) => <span>{title}</span>,
60
+ Received: ({ title }: { title?: string }) => <span>{title}</span>,
61
+ },
62
+ }))
63
+
57
64
  vi.mock('./MessageTag', () => ({
58
65
  MessageTag: () => null,
59
66
  isAttachmentMessage: () => false,
60
67
  isChatbotMessage: () => false,
61
- isMediaAttachmentMessage: () => false,
68
+ isMediaAttachmentMessage: (message: {
69
+ metadata?: { attachment_content_type?: string }
70
+ }) => message.metadata?.attachment_content_type === 'media',
62
71
  isPaidMessage: () => false,
63
- isTextAttachmentMessage: () => false,
72
+ isTextAttachmentMessage: (message: {
73
+ metadata?: { attachment_content_type?: string }
74
+ }) => message.metadata?.attachment_content_type === 'text',
64
75
  hasPaidDeliveryTag: () => false,
65
76
  }))
66
77
 
@@ -70,16 +81,20 @@ vi.mock('./TipMessage', () => ({
70
81
  }))
71
82
 
72
83
  vi.mock('./LockedAttachment', () => ({
73
- default: () => null,
84
+ default: () => <span>Locked attachment</span>,
74
85
  }))
75
86
 
76
87
  vi.mock('./StreamAttachmentMessage', () => ({
77
88
  default: () => null,
78
89
  buildOrderedAttachmentSegments: () => [],
90
+ linkCardPropsFromStreamAttachment: (attachment: { title?: string }) => ({
91
+ title: attachment.title,
92
+ }),
79
93
  }))
80
94
 
81
95
  vi.mock('../MediaMessage', () => ({
82
- isLinkAttachment: () => false,
96
+ isLinkAttachment: (attachment: { type?: string; og_scrape_url?: string }) =>
97
+ attachment.type === 'link' || !!attachment.og_scrape_url?.trim(),
83
98
  }))
84
99
 
85
100
  const mockedUseChannelStateContext = vi.mocked(useChannelStateContext)
@@ -261,4 +276,134 @@ describe('CustomMessage', () => {
261
276
 
262
277
  expect(screen.getByText('Message')).toBeInTheDocument()
263
278
  })
279
+
280
+ it('renders a link attachment alongside a locked media attachment', () => {
281
+ mockedUseMessageContext.mockReturnValue({
282
+ message: {
283
+ id: 'locked-media-message',
284
+ text: '',
285
+ type: 'regular',
286
+ status: 'received',
287
+ user: { id: 'me', name: 'Me' },
288
+ metadata: {
289
+ custom_type: 'MESSAGE_ATTACHMENT',
290
+ attachment_content_type: 'media',
291
+ },
292
+ attachments: [
293
+ {
294
+ type: 'link',
295
+ og_scrape_url: 'https://reactjs.org/',
296
+ title: 'ReactJS Philippines',
297
+ },
298
+ ],
299
+ },
300
+ isMyMessage: () => true,
301
+ threadList: false,
302
+ editing: false,
303
+ endOfGroup: true,
304
+ firstOfGroup: true,
305
+ groupedByUser: true,
306
+ handleAction: vi.fn(),
307
+ handleOpenThread: vi.fn(),
308
+ handleRetry: vi.fn(),
309
+ highlighted: false,
310
+ renderText: undefined,
311
+ isMessageAIGenerated: undefined,
312
+ } as never)
313
+
314
+ renderWithProviders(
315
+ <OutboundContext.Provider value={{}}>
316
+ <CustomMessage />
317
+ </OutboundContext.Provider>
318
+ )
319
+
320
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
321
+ expect(screen.getByText('Locked attachment')).toBeInTheDocument()
322
+ })
323
+
324
+ it('renders a link attachment alongside a locked text attachment', () => {
325
+ mockedUseMessageContext.mockReturnValue({
326
+ message: {
327
+ id: 'locked-text-message',
328
+ text: '',
329
+ type: 'regular',
330
+ status: 'received',
331
+ user: { id: 'me', name: 'Me' },
332
+ metadata: {
333
+ custom_type: 'MESSAGE_ATTACHMENT',
334
+ attachment_content_type: 'text',
335
+ },
336
+ attachments: [
337
+ {
338
+ type: 'link',
339
+ og_scrape_url: 'https://reactjs.org/',
340
+ title: 'ReactJS Philippines',
341
+ },
342
+ ],
343
+ },
344
+ isMyMessage: () => true,
345
+ threadList: false,
346
+ editing: false,
347
+ endOfGroup: true,
348
+ firstOfGroup: true,
349
+ groupedByUser: true,
350
+ handleAction: vi.fn(),
351
+ handleOpenThread: vi.fn(),
352
+ handleRetry: vi.fn(),
353
+ highlighted: false,
354
+ renderText: undefined,
355
+ isMessageAIGenerated: undefined,
356
+ } as never)
357
+
358
+ renderWithProviders(
359
+ <OutboundContext.Provider value={{}}>
360
+ <CustomMessage />
361
+ </OutboundContext.Provider>
362
+ )
363
+
364
+ expect(screen.getByText(/reactjs philippines/i)).toBeInTheDocument()
365
+ expect(screen.getByText('Locked attachment')).toBeInTheDocument()
366
+ })
367
+
368
+ it('does not render a link card when a locked message has no link attachments', () => {
369
+ mockedUseMessageContext.mockReturnValue({
370
+ message: {
371
+ id: 'locked-media-without-link',
372
+ text: '',
373
+ type: 'regular',
374
+ status: 'received',
375
+ user: { id: 'me', name: 'Me' },
376
+ metadata: {
377
+ custom_type: 'MESSAGE_ATTACHMENT',
378
+ attachment_content_type: 'media',
379
+ },
380
+ attachments: [
381
+ {
382
+ type: 'image',
383
+ image_url: 'https://cdn.example/image.jpg',
384
+ },
385
+ ],
386
+ },
387
+ isMyMessage: () => true,
388
+ threadList: false,
389
+ editing: false,
390
+ endOfGroup: true,
391
+ firstOfGroup: true,
392
+ groupedByUser: true,
393
+ handleAction: vi.fn(),
394
+ handleOpenThread: vi.fn(),
395
+ handleRetry: vi.fn(),
396
+ highlighted: false,
397
+ renderText: undefined,
398
+ isMessageAIGenerated: undefined,
399
+ } as never)
400
+
401
+ renderWithProviders(
402
+ <OutboundContext.Provider value={{}}>
403
+ <CustomMessage />
404
+ </OutboundContext.Provider>
405
+ )
406
+
407
+ expect(screen.queryByText(/reactjs philippines/i)).not.toBeInTheDocument()
408
+ })
264
409
  })
@@ -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,7 @@ import { OutboundContext } from './OutboundContext'
49
50
  import { SentMessageDeliveryStatus } from './SentMessageDeliveryStatus'
50
51
  import StreamAttachmentMessage, {
51
52
  buildOrderedAttachmentSegments,
53
+ linkCardPropsFromStreamAttachment,
52
54
  } from './StreamAttachmentMessage'
53
55
  import { TipMessage, isTipMessage } from './TipMessage'
54
56
 
@@ -123,6 +125,10 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
123
125
  const filtered = raw.filter((a) => !('type' in a) || !isLinkAttachment(a))
124
126
  return filtered.length === raw.length ? raw : filtered
125
127
  }, [message])
128
+ const lockedLinkAttachments = useMemo(
129
+ () => (message.attachments ?? []).filter(isLinkAttachment),
130
+ [message]
131
+ )
126
132
  const attachmentSegments = useMemo(
127
133
  () => buildOrderedAttachmentSegments(finalAttachments),
128
134
  [finalAttachments]
@@ -270,6 +276,27 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
270
276
  {showTail && <MessageTail side={isMine ? 'me' : 'other'} />}
271
277
  </div>
272
278
  )
279
+ const lockedLinkCards = lockedLinkAttachments.length > 0 && (
280
+ <div
281
+ className={classNames(
282
+ 'flex flex-col gap-[4px]',
283
+ isMine ? 'items-end' : 'items-start'
284
+ )}
285
+ >
286
+ {lockedLinkAttachments.map((attachment, index) => {
287
+ const Card = isMine ? LinkAttachment.Sent : LinkAttachment.Received
288
+ const isFinalRow =
289
+ index === lockedLinkAttachments.length - 1 && !message.text
290
+ return (
291
+ <Card
292
+ key={`locked-link-${index}`}
293
+ {...linkCardPropsFromStreamAttachment(attachment)}
294
+ groupPosition={isFinalRow ? bubbleGroupPosition : 'middle'}
295
+ />
296
+ )
297
+ })}
298
+ </div>
299
+ )
273
300
  return (
274
301
  <>
275
302
  {editing && (
@@ -340,6 +367,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
340
367
  )}
341
368
  {!isMine && MessageActions && <MessageActions />}
342
369
  </div>
370
+ {lockedLinkCards}
343
371
  </div>
344
372
  ) : isMediaAttachmentMessage(message) ? (
345
373
  <div className="str-chat__message-bubble-wrapper message-locked-attachment-wrapper">
@@ -374,6 +402,7 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
374
402
  )}
375
403
  {!isMine && MessageActions && <MessageActions />}
376
404
  </div>
405
+ {lockedLinkCards}
377
406
  {message.text && accompanyingTextBubble}
378
407
  </div>
379
408
  ) : canRenderAttachmentsInToolkit ? (
@@ -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)