@linktr.ee/messaging-react 4.2.0-rc-1787325521 → 4.2.0-rc-1787599724

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 (96) hide show
  1. package/dist/{Card-BYJyA83X.js → Card-D7UPq5-g.js} +2 -2
  2. package/dist/{Card-BYJyA83X.js.map → Card-D7UPq5-g.js.map} +1 -1
  3. package/dist/{Card-BplJtZao.cjs → Card-DcBZqMBt.cjs} +2 -2
  4. package/dist/{Card-BplJtZao.cjs.map → Card-DcBZqMBt.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-BUC9vqhJ.js → index-CSkr_CO5.js} +2089 -1952
  7. package/dist/index-CSkr_CO5.js.map +1 -0
  8. package/dist/index-CwkY7QwT.cjs +3 -0
  9. package/dist/index-CwkY7QwT.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +59 -78
  12. package/dist/index.js +26 -30
  13. package/dist/testing.cjs +1 -1
  14. package/dist/testing.cjs.map +1 -1
  15. package/dist/testing.js +2 -2
  16. package/dist/testing.js.map +1 -1
  17. package/package.json +13 -11
  18. package/src/components/AttachmentCard/utils/icons.ts +7 -6
  19. package/src/components/Avatar/Avatar.test.tsx +0 -126
  20. package/src/components/Avatar/getAvatarEmoji.test.ts +0 -39
  21. package/src/components/ChannelList/CustomChannelPreview.test.tsx +3 -410
  22. package/src/components/ChannelList/index.test.tsx +1 -132
  23. package/src/components/ChannelView.stories.tsx +0 -4
  24. package/src/components/ChannelView.test.tsx +3 -9
  25. package/src/components/CustomMessage/CustomMessage.stories.tsx +314 -6
  26. package/src/components/CustomMessage/CustomMessage.test.tsx +2 -56
  27. package/src/components/CustomMessage/MessageTag.stories.tsx +53 -17
  28. package/src/components/CustomMessage/MessageTag.test.tsx +40 -7
  29. package/src/components/CustomMessage/MessageTag.tsx +39 -7
  30. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +7 -8
  31. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +1 -2
  32. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
  33. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +12 -5
  34. package/src/components/CustomMessage/index.tsx +19 -22
  35. package/src/components/CustomMessageInput/index.tsx +1 -1
  36. package/src/components/CustomSystemMessage/index.tsx +2 -3
  37. package/src/components/LinkAttachment/LinkAttachment.test.tsx +0 -327
  38. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
  39. package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +153 -0
  40. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +450 -0
  41. package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
  42. package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
  43. package/src/components/MessageAttachment/Audio/index.tsx +83 -77
  44. package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
  45. package/src/components/MessageAttachment/_shared/fileMeta.test.ts +0 -44
  46. package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
  47. package/src/components/MessageAttachment/index.tsx +25 -9
  48. package/src/components/MessageAttachment/types.ts +19 -9
  49. package/src/components/MessagingShell/MessagingShell.test.tsx +0 -20
  50. package/src/components/MessagingShell/index.tsx +0 -2
  51. package/src/components/RichCard/RichCard.test.tsx +1 -3
  52. package/src/index.ts +0 -4
  53. package/src/providers/MessagingProvider.test.tsx +2 -130
  54. package/src/providers/MessagingProvider.tsx +15 -26
  55. package/src/styles.css +210 -57
  56. package/src/testing/createMockMessagingClient.ts +2 -2
  57. package/src/types.ts +0 -9
  58. package/dist/index-BUC9vqhJ.js.map +0 -1
  59. package/dist/index-BkqyKPJP.cjs +0 -3
  60. package/dist/index-BkqyKPJP.cjs.map +0 -1
  61. package/src/components/AttachmentCard/MediaPlayer.test.tsx +0 -346
  62. package/src/components/AttachmentCard/Thumbnail.test.tsx +0 -215
  63. package/src/components/AttachmentCard/index.test.tsx +0 -160
  64. package/src/components/ChannelList/ChannelListContext.test.tsx +0 -87
  65. package/src/components/CustomMessage/Locked.stories.tsx +0 -108
  66. package/src/components/CustomMessage/PaidMessage.stories.tsx +0 -42
  67. package/src/components/CustomMessage/Tip.stories.tsx +0 -48
  68. package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +0 -51
  69. package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +0 -65
  70. package/src/components/CustomMessage/TipMessage/index.tsx +0 -27
  71. package/src/components/FaqList/FaqList.test.tsx +0 -204
  72. package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +0 -76
  73. package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +0 -248
  74. package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +0 -123
  75. package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +0 -193
  76. package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +0 -242
  77. package/src/components/LinkAttachment/components/_shared/hex.test.ts +0 -37
  78. package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +0 -72
  79. package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +0 -162
  80. package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +0 -182
  81. package/src/components/MessageAttachment/_shared/Bubble.test.tsx +0 -111
  82. package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +0 -36
  83. package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +0 -114
  84. package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +0 -42
  85. package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +0 -168
  86. package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +0 -52
  87. package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -69
  88. package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +0 -45
  89. package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +0 -55
  90. package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +0 -246
  91. package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +0 -113
  92. package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +0 -68
  93. package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +0 -16
  94. package/src/logging/index.test.tsx +0 -136
  95. package/src/logging/index.tsx +0 -48
  96. package/src/stories/customMessageStoryTemplate.tsx +0 -202
@@ -1,93 +1,75 @@
1
1
  import React from 'react'
2
+ import { useMessageContext } from 'stream-chat-react'
2
3
 
3
4
  import Bubble from '../_shared/Bubble'
4
- import DismissButton from '../_shared/DismissButton'
5
5
  import {
6
6
  bubbleVariantForState,
7
7
  type AudioItem,
8
8
  type ComposerExtras,
9
- type MediaPreloadMode,
10
9
  type MessageAttachmentBaseProps,
11
10
  type MessageAttachmentState,
12
11
  } from '../types'
13
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
+
14
30
  export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
15
31
  /** Audio source URL (`mp3`, `aac`, `wav`, …). */
16
32
  src?: string
17
- /** MIME type hint — typed onto the inline `<source>` element. */
33
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
18
34
  mimeType?: string
19
35
  /**
20
- * Filename — used as the download default name (consumed by the
21
- * native player's kebab menu). The HTML `<audio>` element doesn't
22
- * expose a built-in title slot, so we don't render the filename
23
- * inside the bubble itself.
36
+ * Filename — the download default name for the trailing action, and
37
+ * part of each control's accessible name.
24
38
  */
25
39
  filename?: string
26
40
  /**
27
41
  * Stacked audio. Takes precedence over `src` when set. Each item
28
- * renders its own native `<audio controls>` player, vertically
29
- * stacked inside the same bubble with an 8px gap between players.
30
- * Sent + Received only the composer surface accepts a single
31
- * 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.
32
45
  */
33
46
  items?: AudioItem[]
34
- /**
35
- * `<audio preload>` hint applied to every player on the bubble.
36
- * When omitted, the default depends on the rendered shape:
37
- *
38
- * - Single audio (no `items`, or `items.length === 1`) →
39
- * `'metadata'` so the native player surfaces duration
40
- * immediately.
41
- * - Stacked audio (`items.length > 1`) → `'none'` so a thread
42
- * of voice memos doesn't fan out N parallel metadata requests
43
- * on first paint.
44
- *
45
- * Per-track overrides live on `AudioItem.preload`.
46
- */
47
- 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[]
48
51
  }
49
52
 
50
53
  const resolveItems = ({
51
54
  src,
52
55
  mimeType,
53
56
  filename,
57
+ durationSeconds,
58
+ waveformData,
54
59
  items,
55
60
  }: {
56
61
  src?: string
57
62
  mimeType?: string
58
63
  filename?: string
64
+ durationSeconds?: number
65
+ waveformData?: number[]
59
66
  items?: AudioItem[]
60
67
  }): AudioItem[] => {
61
68
  if (items && items.length > 0) return items
62
- if (src) return [{ src, mimeType, filename }]
69
+ if (src) return [{ src, mimeType, filename, durationSeconds, waveformData }]
63
70
  return []
64
71
  }
65
72
 
66
- const NativeAudioPlayer: React.FC<{
67
- item: AudioItem
68
- preload: MediaPreloadMode
69
- trailingAction?: React.ReactNode
70
- }> = ({ item, preload, trailingAction }) => (
71
- <div className="flex items-center gap-2">
72
- {/* No `<track>` is rendered — we don't author caption sidecars
73
- for chat attachments, and an empty `<track kind="captions" />`
74
- emits a runtime warning. Re-add a real track (with `src`,
75
- `srcLang`, and `default`) once caption support actually ships.
76
- The `jsx-a11y/media-has-caption` rule is suppressed for the
77
- same reason. */}
78
- {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
79
- <audio
80
- src={item.src}
81
- controls
82
- preload={item.preload ?? preload}
83
- className="block min-w-0 flex-1"
84
- >
85
- {item.mimeType ? <source src={item.src} type={item.mimeType} /> : null}
86
- </audio>
87
- {trailingAction ?? null}
88
- </div>
89
- )
90
-
91
73
  interface InternalAudioRowProps extends AudioAttachmentSharedProps {
92
74
  state: MessageAttachmentState
93
75
  onDismiss?: () => void
@@ -99,52 +81,74 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
99
81
  mimeType,
100
82
  filename,
101
83
  items,
84
+ durationSeconds,
85
+ waveformData,
102
86
  text,
103
87
  groupPosition,
104
- preload,
105
88
  onDismiss,
106
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
+
107
102
  const variant = bubbleVariantForState(state)
108
- const showDismiss = state === 'composer' && !!onDismiss
109
- const resolvedItems = resolveItems({ src, mimeType, filename, items })
103
+ const resolvedItems = resolveItems({
104
+ src,
105
+ mimeType,
106
+ filename,
107
+ durationSeconds,
108
+ waveformData,
109
+ items,
110
+ })
110
111
 
111
112
  if (resolvedItems.length === 0) {
112
113
  return null
113
114
  }
114
115
 
115
- // Default rule: surface duration immediately for a single player,
116
- // but avoid fanning out N metadata requests when the bubble is a
117
- // thread of voice memos. An explicit `preload` prop overrides;
118
- // per-track overrides live on `AudioItem.preload`.
119
- const resolvedPreload: MediaPreloadMode =
120
- 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' }
121
132
 
122
133
  return (
123
134
  <Bubble
124
135
  variant={variant}
125
136
  text={text}
126
137
  groupPosition={groupPosition}
138
+ bordered={false}
139
+ widthClassName={CARD_WIDTH_CLASS}
140
+ paddingClassName={CARD_PADDING_CLASS}
141
+ radiusClassName={CARD_RADIUS_CLASS}
127
142
  data-testid="audio-attachment"
128
143
  >
129
- {/* Native `<audio controls>` already exposes a download in its
130
- kebab menu, so we don't render a separate Download button.
131
- The element also has no built-in title slot — the dismiss
132
- button (Composer only) sits inline next to the player.
133
- Stacked players get an 8px vertical gap so each track reads
134
- as a discrete attachment. */}
135
144
  <div className="flex flex-col gap-2">
136
145
  {resolvedItems.map((item, index) => (
137
- <NativeAudioPlayer
146
+ <WaveformAudioRow
138
147
  key={`${item.src}-${index}`}
139
148
  item={item}
140
- preload={resolvedPreload}
141
- trailingAction={
142
- // Composer only supports a single attachment, so the
143
- // dismiss control sits on the only player.
144
- showDismiss && index === 0 ? (
145
- <DismissButton onClick={onDismiss!} variant="inline" />
146
- ) : undefined
147
- }
149
+ variant={variant}
150
+ requester={audioRowRequester(requesterBase, index)}
151
+ trailingAction={trailingAction}
148
152
  />
149
153
  ))}
150
154
  </div>
@@ -164,8 +168,10 @@ export interface AudioComposerProps extends ComposerExtras {
164
168
  src?: string
165
169
  mimeType?: string
166
170
  filename?: string
167
- /** See `AudioAttachmentSharedProps.preload`. */
168
- preload?: MediaPreloadMode
171
+ /** See `AudioItem.durationSeconds`. */
172
+ durationSeconds?: number
173
+ /** See `AudioItem.waveformData`. */
174
+ waveformData?: number[]
169
175
  }
170
176
  export type AudioSentProps = AudioAttachmentSharedProps
171
177
  export type AudioReceivedProps = AudioAttachmentSharedProps
@@ -1,4 +1,9 @@
1
1
  import React from 'react'
2
+ import {
3
+ ChatProvider,
4
+ WithAudioPlayback,
5
+ type ChatContextValue,
6
+ } from 'stream-chat-react'
2
7
  import { beforeEach, describe, expect, it, vi } from 'vitest'
3
8
 
4
9
  import { renderWithProviders, screen, fireEvent } from '../../test/utils'
@@ -7,6 +12,21 @@ import { triggerDownload } from './_shared/triggerDownload'
7
12
 
8
13
  import MessageAttachment, { bubbleGroupPositionFromStream } from '.'
9
14
 
15
+ // `MessageAttachment.Audio`'s playback comes from `stream-chat-react`'s
16
+ // pooled `AudioPlayer`, which `<Channel>` mounts in production. Mounting
17
+ // the pool directly is the same contract without needing a channel that
18
+ // can `watch()`. The player reads only `client` off the chat context —
19
+ // optional-chained, for error notifications — so a stub is enough; its
20
+ // presence is what stops `useChatContext` warning.
21
+ const AUDIO_CHAT_CONTEXT = {} as ChatContextValue
22
+
23
+ const renderAudio = (ui: React.ReactElement) =>
24
+ renderWithProviders(
25
+ <ChatProvider value={AUDIO_CHAT_CONTEXT}>
26
+ <WithAudioPlayback>{ui}</WithAudioPlayback>
27
+ </ChatProvider>
28
+ )
29
+
10
30
  vi.mock('./_shared/triggerDownload', () => ({
11
31
  triggerDownload: vi.fn(() => Promise.resolve()),
12
32
  }))
@@ -716,50 +736,186 @@ describe('MessageAttachment.File', () => {
716
736
  })
717
737
 
718
738
  describe('MessageAttachment.Audio', () => {
719
- it('renders the native audio player only (no title, no extra download)', () => {
720
- renderWithProviders(
739
+ // 12 samples so the resample path is exercised on the way to the
740
+ // design's 35 bars.
741
+ const WAVEFORM = [
742
+ 0.1, 0.8, 0.65, 0.2, 0.3, 0.45, 0.5, 0.7, 1, 0.85, 0.4, 0.15,
743
+ ]
744
+
745
+ it('renders the waveform card: bars, play toggle, duration and seek', () => {
746
+ renderAudio(
721
747
  <MessageAttachment.Audio.Sent
722
748
  src="https://cdn.example.com/song.mp3"
723
749
  mimeType="audio/mpeg"
724
750
  filename="song.mp3"
751
+ durationSeconds={12}
752
+ waveformData={WAVEFORM}
725
753
  />
726
754
  )
755
+
727
756
  const bubble = screen.getByTestId('audio-attachment')
728
- expect(bubble.querySelector('audio')).not.toBeNull()
729
- // No filename title — the native `<audio>` element doesn't expose
730
- // a title slot, so we render just the player.
731
- expect(screen.queryByText('song.mp3')).toBeNull()
732
- // Native player handles its own download in the kebab menu, so
733
- // no extra Download button should be rendered.
734
- expect(screen.queryByRole('button', { name: /download/i })).toBeNull()
757
+ expect(bubble.querySelectorAll('[data-testid="amplitude-bar"]')).toHaveLength(
758
+ 35
759
+ )
760
+ // The native player is gone — playback is our own control set now.
761
+ expect(bubble.querySelector('audio')).toBeNull()
762
+ expect(
763
+ screen.getByRole('button', { name: 'Play audio — song.mp3' })
764
+ ).toBeInTheDocument()
765
+ expect(screen.getByText('00:12')).toBeInTheDocument()
766
+
767
+ const seek = screen.getByRole('slider', { name: 'Seek audio — song.mp3' })
768
+ expect(seek).toHaveAttribute('aria-valuetext', '00:00 of 00:12')
769
+ expect(seek).toHaveValue('0')
770
+
771
+ // The bars duplicate what the slider already announces, so they
772
+ // must stay out of the accessibility tree — otherwise they read as
773
+ // 35 unnamed nodes and (in Stream's own version of this widget) as
774
+ // an unnamed `progressbar`.
775
+ const track = bubble.querySelector('.mes-audio-wave__track')
776
+ expect(track).toHaveAttribute('aria-hidden', 'true')
777
+ })
778
+
779
+ it('renders the full bar count as a flat placeholder when no waveform exists', () => {
780
+ // The total fallback: a decode failure, an unsupported codec, a
781
+ // re-staged zero-byte placeholder, or anything sent before
782
+ // `waveform_data` was persisted. Passing `[]` straight through
783
+ // would render no bars at all, so the card must supply its own
784
+ // all-zero row — the card's silhouette has to be unchanged.
785
+ renderAudio(
786
+ <MessageAttachment.Audio.Received src="https://cdn.example.com/legacy.mp3" />
787
+ )
788
+
789
+ const bars = screen.getAllByTestId('amplitude-bar')
790
+ expect(bars).toHaveLength(35)
791
+ bars.forEach((bar) => {
792
+ expect(bar).not.toHaveClass('mes-audio-wave__bar--played')
793
+ })
735
794
  })
736
795
 
737
- it('shows an inline dismiss button next to the player on Composer state', () => {
796
+ it.each([3, 12, 35, 200])(
797
+ 'resamples a %i-sample waveform to the rendered bar count',
798
+ (sampleCount) => {
799
+ const data = Array.from({ length: sampleCount }, (_, i) =>
800
+ Math.abs(Math.sin(i))
801
+ )
802
+ renderAudio(
803
+ <MessageAttachment.Audio.Sent
804
+ src="https://cdn.example.com/song.mp3"
805
+ waveformData={data}
806
+ />
807
+ )
808
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
809
+ }
810
+ )
811
+
812
+ it('shows an unknown duration without collapsing the slot', () => {
813
+ renderAudio(
814
+ <MessageAttachment.Audio.Sent src="https://cdn.example.com/legacy.mp3" />
815
+ )
816
+
817
+ expect(screen.getByText('--:--')).toBeInTheDocument()
818
+ expect(screen.getByRole('slider', { name: 'Seek audio' })).toHaveAttribute(
819
+ 'aria-valuetext',
820
+ '00:00 of --:--'
821
+ )
822
+ })
823
+
824
+ it('removes the draft from the composer trailing action', () => {
825
+ // There is no product-owned overflow menu on audio attachments in
826
+ // production, so the design's trailing control carries forward the
827
+ // one action each surface already had: Remove in the composer…
738
828
  const onDismiss = vi.fn()
739
- renderWithProviders(
829
+ renderAudio(
740
830
  <MessageAttachment.Audio.Composer
741
831
  src="https://cdn.example.com/song.mp3"
832
+ filename="song.mp3"
742
833
  onDismiss={onDismiss}
743
834
  />
744
835
  )
745
- const dismiss = screen.getByLabelText('Dismiss attachment')
746
- expect(dismiss).toBeInTheDocument()
747
- fireEvent.click(dismiss)
836
+
837
+ expect(
838
+ screen.queryByRole('button', { name: /download/i })
839
+ ).not.toBeInTheDocument()
840
+ fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
748
841
  expect(onDismiss).toHaveBeenCalledTimes(1)
749
842
  })
750
843
 
751
- it('renders one native player per item for stacked audio', () => {
752
- renderWithProviders(
844
+ it('downloads from the sent trailing action', () => {
845
+ // …and Download once sent, preserving the capability the native
846
+ // `<audio controls>` kebab used to provide.
847
+ renderAudio(
848
+ <MessageAttachment.Audio.Sent
849
+ src="https://cdn.example.com/song.mp3"
850
+ filename="song.mp3"
851
+ />
852
+ )
853
+
854
+ fireEvent.click(
855
+ screen.getByRole('button', { name: 'Download audio — song.mp3' })
856
+ )
857
+ expect(mockTriggerDownload).toHaveBeenCalledWith(
858
+ 'https://cdn.example.com/song.mp3',
859
+ 'song.mp3'
860
+ )
861
+ })
862
+
863
+ it('renders one waveform row per item for stacked audio', () => {
864
+ renderAudio(
753
865
  <MessageAttachment.Audio.Sent
754
866
  items={[
755
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
756
- { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
757
- { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
867
+ { src: 'https://cdn.example.com/a.mp3', durationSeconds: 12 },
868
+ { src: 'https://cdn.example.com/b.mp3', durationSeconds: 30 },
869
+ { src: 'https://cdn.example.com/c.mp3', durationSeconds: 65 },
758
870
  ]}
759
871
  />
760
872
  )
873
+
761
874
  const bubble = screen.getByTestId('audio-attachment')
762
- expect(bubble.querySelectorAll('audio')).toHaveLength(3)
875
+ expect(
876
+ bubble.querySelectorAll('[data-testid="audio-attachment-row"]')
877
+ ).toHaveLength(3)
878
+ expect(screen.getByText('01:05')).toBeInTheDocument()
879
+ })
880
+
881
+ it('disables playback for a MIME type the browser cannot play, but keeps the download', () => {
882
+ // `canPlayType` gates playback, and clicking play on an unplayable
883
+ // track is a no-op inside the player anyway — so the control says
884
+ // so. The file must still be retrievable.
885
+ renderAudio(
886
+ <MessageAttachment.Audio.Received
887
+ src="https://cdn.example.com/master.flac"
888
+ mimeType="audio/flac"
889
+ filename="master.flac"
890
+ />
891
+ )
892
+
893
+ expect(
894
+ screen.getByRole('button', { name: /^Play audio/ })
895
+ ).toBeDisabled()
896
+ expect(screen.getByRole('slider', { name: /^Seek audio/ })).toBeDisabled()
897
+ expect(
898
+ screen.getByRole('button', { name: /^Download audio/ })
899
+ ).toBeEnabled()
900
+ })
901
+
902
+ it('still renders the card without the audio-playback provider', () => {
903
+ // The pooled player lives on `<Channel>`. Outside one the card must
904
+ // degrade to an inert card rather than rendering nothing — a blank
905
+ // bubble is undiagnosable, and it would silently break every
906
+ // consumer that mounts an attachment outside a channel.
907
+ renderWithProviders(
908
+ <MessageAttachment.Audio.Sent
909
+ src="https://cdn.example.com/song.mp3"
910
+ durationSeconds={12}
911
+ waveformData={WAVEFORM}
912
+ />
913
+ )
914
+
915
+ expect(screen.getByTestId('audio-attachment')).toBeInTheDocument()
916
+ expect(screen.getAllByTestId('amplitude-bar')).toHaveLength(35)
917
+ expect(screen.getByText('00:12')).toBeInTheDocument()
918
+ expect(screen.getByRole('button', { name: /^Play audio/ })).toBeDisabled()
763
919
  })
764
920
  })
765
921
 
@@ -951,60 +1107,6 @@ describe('MessageAttachment lazy-loading defaults', () => {
951
1107
  expect(video?.getAttribute('preload')).toBe('metadata')
952
1108
  })
953
1109
  })
954
-
955
- describe('Audio', () => {
956
- it('defaults a single-track bubble to preload="metadata"', () => {
957
- renderWithProviders(
958
- <MessageAttachment.Audio.Received
959
- src="https://cdn.example.com/song.mp3"
960
- mimeType="audio/mpeg"
961
- />
962
- )
963
- const audio = screen
964
- .getByTestId('audio-attachment')
965
- .querySelector('audio')
966
- expect(audio?.getAttribute('preload')).toBe('metadata')
967
- })
968
-
969
- it('defaults a stacked (3-track) bubble to preload="none" on every player', () => {
970
- renderWithProviders(
971
- <MessageAttachment.Audio.Sent
972
- items={[
973
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
974
- { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
975
- { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
976
- ]}
977
- />
978
- )
979
- const audios = Array.from(
980
- screen.getByTestId('audio-attachment').querySelectorAll('audio')
981
- )
982
- expect(audios).toHaveLength(3)
983
- audios.forEach((audio) => {
984
- expect(audio.getAttribute('preload')).toBe('none')
985
- })
986
- })
987
-
988
- it('honors a per-track AudioItem.preload override', () => {
989
- renderWithProviders(
990
- <MessageAttachment.Audio.Sent
991
- items={[
992
- { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
993
- {
994
- src: 'https://cdn.example.com/b.mp3',
995
- mimeType: 'audio/mpeg',
996
- preload: 'metadata',
997
- },
998
- ]}
999
- />
1000
- )
1001
- const audios = Array.from(
1002
- screen.getByTestId('audio-attachment').querySelectorAll('audio')
1003
- )
1004
- expect(audios[0]?.getAttribute('preload')).toBe('none')
1005
- expect(audios[1]?.getAttribute('preload')).toBe('metadata')
1006
- })
1007
- })
1008
1110
  })
1009
1111
 
1010
1112
  describe('bubbleGroupPositionFromStream', () => {
@@ -25,14 +25,6 @@ describe('formatFileSize', () => {
25
25
  expect(formatFileSize(Infinity)).toBe('')
26
26
  expect(formatFileSize(-1)).toBe('')
27
27
  })
28
-
29
- it('handles unit boundaries without crossing into the next unit early', () => {
30
- expect(formatFileSize(1023)).toBe('1023 B')
31
- expect(formatFileSize(1024 * 1024 - 1)).toBe('1024.0 KB')
32
- expect(formatFileSize(1024 * 1024)).toBe('1.00 MB')
33
- expect(formatFileSize(1024 ** 3 - 1)).toBe('1024.00 MB')
34
- expect(formatFileSize(1024 ** 3)).toBe('1.00 GB')
35
- })
36
28
  })
37
29
 
38
30
  describe('getFileExtensionLabel', () => {
@@ -51,18 +43,6 @@ describe('getFileExtensionLabel', () => {
51
43
  expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
52
44
  expect(getFileExtensionLabel()).toBeUndefined()
53
45
  })
54
-
55
- it('handles dot boundaries, extension length, and MIME fallbacks', () => {
56
- expect(getFileExtensionLabel('text/plain', '.gitignore')).toBe('TXT')
57
- expect(getFileExtensionLabel('text/plain', 'notes.')).toBe('TXT')
58
- expect(getFileExtensionLabel('image/webp', 'photo.webp')).toBe('WEBP')
59
- expect(getFileExtensionLabel('image/webp', 'photo.abcdef')).toBe('WEBP')
60
- expect(getFileExtensionLabel('application/json', 'data.12345')).toBe(
61
- '12345'
62
- )
63
- expect(getFileExtensionLabel('image/*')).toBeUndefined()
64
- expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
65
- })
66
46
  })
67
47
 
68
48
  describe('buildCompactMetaLabel', () => {
@@ -79,23 +59,6 @@ describe('buildCompactMetaLabel', () => {
79
59
  it('returns undefined when nothing is renderable', () => {
80
60
  expect(buildCompactMetaLabel()).toBeUndefined()
81
61
  })
82
-
83
- it('drops non-positive and non-number sizes while retaining size-only labels', () => {
84
- expect(buildCompactMetaLabel('application/pdf', 'report.pdf', 0)).toBe(
85
- 'PDF'
86
- )
87
- expect(buildCompactMetaLabel('application/pdf', 'report.pdf', -1)).toBe(
88
- 'PDF'
89
- )
90
- expect(buildCompactMetaLabel(undefined, undefined, 1024)).toBe('1.0 KB')
91
- expect(
92
- buildCompactMetaLabel(
93
- 'application/octet-stream',
94
- undefined,
95
- '1024' as never
96
- )
97
- ).toBeUndefined()
98
- })
99
62
  })
100
63
 
101
64
  describe('filenameFromUrl', () => {
@@ -114,11 +77,4 @@ describe('filenameFromUrl', () => {
114
77
  it('falls back to a default when the URL cannot be parsed', () => {
115
78
  expect(filenameFromUrl('not a url at all')).toBe('download')
116
79
  })
117
-
118
- it('handles trailing slashes, deep paths, and query strings', () => {
119
- expect(filenameFromUrl('https://cdn.example.com/folder/')).toBe('download')
120
- expect(
121
- filenameFromUrl('https://cdn.example.com/a/b/c/report.pdf?download=1')
122
- ).toBe('report.pdf')
123
- })
124
80
  })
@@ -0,0 +1,23 @@
1
+ import { useSyncExternalStore } from 'react'
2
+
3
+ /** No store to subscribe to — the value flips once, at hydration. */
4
+ const subscribe = () => () => {}
5
+ const getSnapshot = () => true
6
+ const getServerSnapshot = () => false
7
+
8
+ /**
9
+ * `false` while server-rendering and for the hydration render, `true`
10
+ * from the first client render onwards.
11
+ *
12
+ * `useSyncExternalStore` is what makes that safe: React uses
13
+ * `getServerSnapshot` for both the server pass and hydration, so the
14
+ * two trees match and only then does the component re-render as
15
+ * interactive. The `useState` + `useEffect` spelling of this produces
16
+ * the same result but trips `react-hooks/set-state-in-effect`.
17
+ *
18
+ * Used to keep browser-only work out of a server render — see
19
+ * `WaveformAudioRow`, where constructing Stream's pooled `AudioPlayer`
20
+ * reaches for `new Audio()` (MES-1360).
21
+ */
22
+ export const useIsClient = (): boolean =>
23
+ useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)