@linktr.ee/messaging-react 4.4.7 → 4.5.0-rc-1788297811

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.
Files changed (54) hide show
  1. package/dist/assets/index.css +1 -1
  2. package/dist/index.cjs +2 -2
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.ts +58 -43
  5. package/dist/index.js +1167 -1033
  6. package/dist/index.js.map +1 -1
  7. package/package.json +3 -3
  8. package/src/components/AttachmentCard/MediaPlayer.test.tsx +9 -5
  9. package/src/components/AttachmentCard/Thumbnail.test.tsx +4 -0
  10. package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +18 -10
  11. package/src/components/ChannelList/ChannelListContext.test.tsx +7 -5
  12. package/src/components/ChannelList/CustomChannelPreview.test.tsx +7 -5
  13. package/src/components/ChannelList/index.test.tsx +1 -0
  14. package/src/components/ChannelView.test.tsx +20 -13
  15. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +4 -3
  16. package/src/components/CustomMessage/CustomMessage.test.tsx +6 -5
  17. package/src/components/CustomMessage/MessageTag.test.tsx +6 -0
  18. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +19 -9
  19. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +45 -5
  20. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
  21. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +3 -0
  22. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +16 -7
  23. package/src/components/FaqList/FaqList.test.tsx +20 -12
  24. package/src/components/LinkAttachment/LinkAttachment.test.tsx +27 -14
  25. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +1 -0
  26. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +6 -6
  27. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +8 -5
  28. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +7 -5
  29. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +3 -8
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  31. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +155 -0
  32. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +455 -0
  33. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  34. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  35. package/src/components/MessageAttachment/Audio/index.tsx +83 -79
  36. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +22 -15
  37. package/src/components/MessageAttachment/MessageAttachment.test.tsx +279 -134
  38. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +2 -0
  39. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +5 -4
  40. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +4 -3
  41. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +4 -3
  42. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +8 -5
  43. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +6 -3
  44. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +11 -4
  45. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +5 -3
  46. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +12 -3
  47. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +13 -6
  48. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +10 -4
  49. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  50. package/src/components/MessageAttachment/index.tsx +25 -9
  51. package/src/components/MessageAttachment/types.ts +19 -9
  52. package/src/components/MessageBubble/MessageBubble.test.tsx +8 -0
  53. package/src/components/RichCard/RichCard.test.tsx +10 -2
  54. package/src/styles.css +120 -0
@@ -1,95 +1,75 @@
1
1
  import React from 'react'
2
+ import { useMessageContext } from 'stream-chat-react'
2
3
 
3
- import { nativePlayerProps } from '../../../utils/nativeMediaPlayers'
4
4
  import Bubble from '../_shared/Bubble'
5
- import DismissButton from '../_shared/DismissButton'
6
5
  import {
7
6
  bubbleVariantForState,
8
7
  type AudioItem,
9
8
  type ComposerExtras,
10
- type MediaPreloadMode,
11
9
  type MessageAttachmentBaseProps,
12
10
  type MessageAttachmentState,
13
11
  } from '../types'
14
12
 
13
+ import { audioRowRequester } from './audioRowRequester'
14
+ import WaveformAudioRow, {
15
+ type AudioRowTrailingAction,
16
+ } from './WaveformAudioRow'
17
+
18
+ // Figma `2754:17710` → `attachment-audio` (`…;2754:8062`): a 299×72
19
+ // card on `#1E2330` with a 20/12/20/20 inset, a uniform 20px radius and
20
+ // no stroke. `@linktr.ee/component-library@11.8.6`'s radius scale is
21
+ // 8 / 16 / 24 / 64 / full, so there is no 20px token to reach for.
22
+ //
23
+ // The radius is uniform in every state, so the card opts out of the
24
+ // same-author corner flattening the text bubbles apply — a 32px control
25
+ // tucked into a 4px corner would collide with it.
26
+ const CARD_WIDTH_CLASS = 'w-[299px]'
27
+ const CARD_PADDING_CLASS = 'py-5 pl-5 pr-3'
28
+ const CARD_RADIUS_CLASS = 'rounded-[20px]'
29
+
15
30
  export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
16
31
  /** Audio source URL (`mp3`, `aac`, `wav`, …). */
17
32
  src?: string
18
- /** MIME type hint — typed onto the inline `<source>` element. */
33
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
19
34
  mimeType?: string
20
35
  /**
21
- * Filename — used as the download default name (consumed by the
22
- * native player's kebab menu). The HTML `<audio>` element doesn't
23
- * expose a built-in title slot, so we don't render the filename
24
- * inside the bubble itself.
36
+ * Filename — the download default name for the trailing action, and
37
+ * part of each control's accessible name.
25
38
  */
26
39
  filename?: string
27
40
  /**
28
41
  * Stacked audio. Takes precedence over `src` when set. Each item
29
- * renders its own native `<audio controls>` player, vertically
30
- * stacked inside the same bubble with an 8px gap between players.
31
- * Sent + Received only the composer surface accepts a single
32
- * attachment at a time.
42
+ * renders its own waveform row, vertically stacked inside the same
43
+ * bubble with an 8px gap. Sent + Received only — the composer surface
44
+ * accepts a single attachment at a time.
33
45
  */
34
46
  items?: AudioItem[]
35
- /**
36
- * `<audio preload>` hint applied to every player on the bubble.
37
- * When omitted, the default depends on the rendered shape:
38
- *
39
- * - Single audio (no `items`, or `items.length === 1`) →
40
- * `'metadata'` so the native player surfaces duration
41
- * immediately.
42
- * - Stacked audio (`items.length > 1`) → `'none'` so a thread
43
- * of voice memos doesn't fan out N parallel metadata requests
44
- * on first paint.
45
- *
46
- * Per-track overrides live on `AudioItem.preload`.
47
- */
48
- preload?: MediaPreloadMode
47
+ /** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
48
+ durationSeconds?: number
49
+ /** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
50
+ waveformData?: number[]
49
51
  }
50
52
 
51
53
  const resolveItems = ({
52
54
  src,
53
55
  mimeType,
54
56
  filename,
57
+ durationSeconds,
58
+ waveformData,
55
59
  items,
56
60
  }: {
57
61
  src?: string
58
62
  mimeType?: string
59
63
  filename?: string
64
+ durationSeconds?: number
65
+ waveformData?: number[]
60
66
  items?: AudioItem[]
61
67
  }): AudioItem[] => {
62
68
  if (items && items.length > 0) return items
63
- if (src) return [{ src, mimeType, filename }]
69
+ if (src) return [{ src, mimeType, filename, durationSeconds, waveformData }]
64
70
  return []
65
71
  }
66
72
 
67
- const NativeAudioPlayer: React.FC<{
68
- item: AudioItem
69
- preload: MediaPreloadMode
70
- trailingAction?: React.ReactNode
71
- }> = ({ item, preload, trailingAction }) => (
72
- <div className="flex items-center gap-2">
73
- {/* No `<track>` is rendered — we don't author caption sidecars
74
- for chat attachments, and an empty `<track kind="captions" />`
75
- emits a runtime warning. Re-add a real track (with `src`,
76
- `srcLang`, and `default`) once caption support actually ships.
77
- The `jsx-a11y/media-has-caption` rule is suppressed for the
78
- same reason. */}
79
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
80
- <audio
81
- {...nativePlayerProps}
82
- src={item.src}
83
- controls
84
- preload={item.preload ?? preload}
85
- className="block min-w-0 flex-1"
86
- >
87
- {item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
88
- </audio>
89
- {trailingAction ?? null}
90
- </div>
91
- )
92
-
93
73
  interface InternalAudioRowProps extends AudioAttachmentSharedProps {
94
74
  state: MessageAttachmentState
95
75
  onDismiss?: () => void
@@ -101,52 +81,74 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
101
81
  mimeType,
102
82
  filename,
103
83
  items,
84
+ durationSeconds,
85
+ waveformData,
104
86
  text,
105
87
  groupPosition,
106
- preload,
107
88
  onDismiss,
108
89
  }) => {
90
+ // Namespaces the pooled players per message, so scrolling a
91
+ // virtualized thread away and back restores the playback position
92
+ // instead of starting a fresh player. Returns `{}` (silently) outside
93
+ // a message, which is the composer case.
94
+ const { message, threadList } = useMessageContext()
95
+ // Fallback namespace for a card rendered outside a message — the
96
+ // composer, a story, a consumer mounting one directly. Stable for the
97
+ // life of the component, which is all those surfaces need; a message
98
+ // id is preferred because it also survives a virtualized thread
99
+ // scrolling the row away and back.
100
+ const instanceId = React.useId()
101
+
109
102
  const variant = bubbleVariantForState(state)
110
- const showDismiss = state === 'composer' && !!onDismiss
111
- const resolvedItems = resolveItems({ src, mimeType, filename, items })
103
+ const resolvedItems = resolveItems({
104
+ src,
105
+ mimeType,
106
+ filename,
107
+ durationSeconds,
108
+ waveformData,
109
+ items,
110
+ })
112
111
 
113
112
  if (resolvedItems.length === 0) {
114
113
  return null
115
114
  }
116
115
 
117
- // Default rule: surface duration immediately for a single player,
118
- // but avoid fanning out N metadata requests when the bubble is a
119
- // thread of voice memos. An explicit `preload` prop overrides;
120
- // per-track overrides live on `AudioItem.preload`.
121
- const resolvedPreload: MediaPreloadMode =
122
- preload ?? (resolvedItems.length > 1 ? 'none' : 'metadata')
116
+ const requesterBase = message?.id
117
+ ? `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`
118
+ : instanceId
119
+
120
+ // The composer's single action is removing the draft; every other
121
+ // surface carries forward the download the native player's own kebab
122
+ // used to provide. See `AudioRowTrailingAction`.
123
+ // A composer draft mounted without `onDismiss` gets no trailing
124
+ // control: falling through to `download` would hand a draft the
125
+ // sent-only action, which the native player it replaces never did.
126
+ const trailingAction: AudioRowTrailingAction | undefined =
127
+ state === 'composer'
128
+ ? onDismiss
129
+ ? { kind: 'dismiss', onDismiss }
130
+ : undefined
131
+ : { kind: 'download' }
123
132
 
124
133
  return (
125
134
  <Bubble
126
135
  variant={variant}
127
136
  text={text}
128
137
  groupPosition={groupPosition}
138
+ bordered={false}
139
+ widthClassName={CARD_WIDTH_CLASS}
140
+ paddingClassName={CARD_PADDING_CLASS}
141
+ radiusClassName={CARD_RADIUS_CLASS}
129
142
  data-testid="audio-attachment"
130
143
  >
131
- {/* Native `<audio controls>` already exposes a download in its
132
- kebab menu, so we don't render a separate Download button.
133
- The element also has no built-in title slot — the dismiss
134
- button (Composer only) sits inline next to the player.
135
- Stacked players get an 8px vertical gap so each track reads
136
- as a discrete attachment. */}
137
144
  <div className="flex flex-col gap-2">
138
145
  {resolvedItems.map((item, index) => (
139
- <NativeAudioPlayer
146
+ <WaveformAudioRow
140
147
  key={`${item.src}-${index}`}
141
148
  item={item}
142
- preload={resolvedPreload}
143
- trailingAction={
144
- // Composer only supports a single attachment, so the
145
- // dismiss control sits on the only player.
146
- showDismiss && index === 0 ? (
147
- <DismissButton onClick={onDismiss!} variant="inline" />
148
- ) : undefined
149
- }
149
+ variant={variant}
150
+ requester={audioRowRequester(requesterBase, index)}
151
+ trailingAction={trailingAction}
150
152
  />
151
153
  ))}
152
154
  </div>
@@ -166,8 +168,10 @@ export interface AudioComposerProps extends ComposerExtras {
166
168
  src?: string
167
169
  mimeType?: string
168
170
  filename?: string
169
- /** See `AudioAttachmentSharedProps.preload`. */
170
- preload?: MediaPreloadMode
171
+ /** See `AudioItem.durationSeconds`. */
172
+ durationSeconds?: number
173
+ /** See `AudioItem.waveformData`. */
174
+ waveformData?: number[]
171
175
  }
172
176
  export type AudioSentProps = AudioAttachmentSharedProps
173
177
  export type AudioReceivedProps = AudioAttachmentSharedProps
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it, vi } from 'vitest'
2
2
 
3
- import { fireEvent, renderWithProviders, screen } from '../../test/utils'
3
+ import { userEvent, renderWithProviders, screen } from '../../test/utils'
4
4
 
5
5
  import { triggerDownload } from './_shared/triggerDownload'
6
6
  import { bubbleVariantForState, bubbleGroupPositionFromStream } from './types'
@@ -14,7 +14,8 @@ vi.mock('./_shared/triggerDownload', () => ({
14
14
  const mockedDownload = vi.mocked(triggerDownload)
15
15
 
16
16
  describe('MessageAttachment behavior', () => {
17
- it('renders file rows with precedence, fallback names, and cancellation', () => {
17
+ it('renders file rows with precedence, fallback names, and cancellation', async () => {
18
+ const user = userEvent.setup()
18
19
  const onClick = vi.fn(() => false)
19
20
  const { rerender } = renderWithProviders(
20
21
  <MessageAttachment.File.Sent
@@ -32,14 +33,14 @@ describe('MessageAttachment behavior', () => {
32
33
  )
33
34
  expect(screen.getByText('first.pdf')).toBeInTheDocument()
34
35
  expect(screen.getByText('second.pdf')).toBeInTheDocument()
35
- fireEvent.click(screen.getByRole('button', { name: 'Download first.pdf' }))
36
+ await user.click(screen.getByRole('button', { name: 'Download first.pdf' }))
36
37
  expect(onClick).toHaveBeenCalledWith(0)
37
38
  expect(mockedDownload).not.toHaveBeenCalled()
38
39
 
39
40
  rerender(
40
41
  <MessageAttachment.File.Sent src="https://cdn.example.com/fallback.txt" />
41
42
  )
42
- fireEvent.click(
43
+ await user.click(
43
44
  screen.getByRole('button', { name: 'Download fallback.txt' })
44
45
  )
45
46
  expect(mockedDownload).toHaveBeenCalledWith(
@@ -48,10 +49,12 @@ describe('MessageAttachment behavior', () => {
48
49
  )
49
50
  })
50
51
 
51
- it('renders no file when inputs are absent and supports composer dismissal', () => {
52
+ it('renders no file when inputs are absent and supports composer dismissal', async () => {
53
+ const user = userEvent.setup()
52
54
  const { container } = renderWithProviders(
53
55
  <MessageAttachment.File.Received />
54
56
  )
57
+ // eslint-disable-next-line testing-library/no-node-access -- the null-rendering DOM contract has no semantic query.
55
58
  expect(container.firstChild).toBeNull()
56
59
  const onDismiss = vi.fn()
57
60
  renderWithProviders(
@@ -60,11 +63,12 @@ describe('MessageAttachment behavior', () => {
60
63
  onDismiss={onDismiss}
61
64
  />
62
65
  )
63
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
66
+ await user.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
64
67
  expect(onDismiss).toHaveBeenCalledOnce()
65
68
  })
66
69
 
67
- it('renders image stacks, forwards loading, and supports click cancellation', () => {
70
+ it('renders image stacks, forwards loading, and supports click cancellation', async () => {
71
+ const user = userEvent.setup()
68
72
  const onClick = vi.fn(() => false)
69
73
  renderWithProviders(
70
74
  <MessageAttachment.Image.Sent
@@ -79,12 +83,13 @@ describe('MessageAttachment behavior', () => {
79
83
  )
80
84
  expect(screen.getAllByRole('img')[0]).toHaveAttribute('loading', 'lazy')
81
85
  expect(screen.getAllByRole('img')).toHaveLength(2)
82
- fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
86
+ await user.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
83
87
  expect(onClick).toHaveBeenCalledWith(1)
84
88
  expect(screen.queryByTestId('image-viewer')).not.toHaveAttribute('open')
85
89
  })
86
90
 
87
- it('opens images at the clicked index and suffixes a shared filename', () => {
91
+ it('opens images at the clicked index and suffixes a shared filename', async () => {
92
+ const user = userEvent.setup()
88
93
  renderWithProviders(
89
94
  <MessageAttachment.Image.Sent
90
95
  filename="photos.jpg"
@@ -94,7 +99,7 @@ describe('MessageAttachment behavior', () => {
94
99
  ]}
95
100
  />
96
101
  )
97
- fireEvent.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
102
+ await user.click(screen.getByRole('button', { name: 'Open image 2 of 2' }))
98
103
  expect(screen.getByTestId('image-viewer')).toHaveAttribute(
99
104
  'aria-label',
100
105
  'photos.jpg (2)'
@@ -105,7 +110,8 @@ describe('MessageAttachment behavior', () => {
105
110
  )
106
111
  })
107
112
 
108
- it('renders video posters and posterless first-frame previews', () => {
113
+ it('renders video posters and posterless first-frame previews', async () => {
114
+ const user = userEvent.setup()
109
115
  const onDismiss = vi.fn()
110
116
  const { rerender } = renderWithProviders(
111
117
  <MessageAttachment.Video.Composer
@@ -119,7 +125,7 @@ describe('MessageAttachment behavior', () => {
119
125
  expect(
120
126
  screen.getByRole('img', { name: 'Video 1 thumbnail' })
121
127
  ).toHaveAttribute('src', '/poster.jpg')
122
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
128
+ await user.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
123
129
  expect(onDismiss).toHaveBeenCalledOnce()
124
130
  rerender(
125
131
  <MessageAttachment.Video.Sent src="https://cdn.example.com/video.mp4" />
@@ -172,7 +178,8 @@ describe('MessageAttachment behavior', () => {
172
178
  expect(screen.queryByLabelText('Video 1 thumbnail')).not.toBeInTheDocument()
173
179
  })
174
180
 
175
- it('supports PDF titles, download actions, dismiss, and viewer index', () => {
181
+ it('supports PDF titles, download actions, dismiss, and viewer index', async () => {
182
+ const user = userEvent.setup()
176
183
  const onDismiss = vi.fn()
177
184
  renderWithProviders(
178
185
  <MessageAttachment.Pdf.Composer
@@ -182,7 +189,7 @@ describe('MessageAttachment behavior', () => {
182
189
  />
183
190
  )
184
191
  expect(screen.getByText('Quarterly report')).toBeInTheDocument()
185
- fireEvent.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
192
+ await user.click(screen.getByRole('button', { name: 'Dismiss attachment' }))
186
193
  expect(onDismiss).toHaveBeenCalledOnce()
187
194
 
188
195
  renderWithProviders(
@@ -193,7 +200,7 @@ describe('MessageAttachment behavior', () => {
193
200
  ]}
194
201
  />
195
202
  )
196
- fireEvent.click(screen.getByRole('button', { name: 'Open two.pdf' }))
203
+ await user.click(screen.getByRole('button', { name: 'Open two.pdf' }))
197
204
  expect(screen.getAllByTestId('pdf-viewer').at(-1)).toHaveAttribute(
198
205
  'aria-label',
199
206
  'two.pdf'