@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
@@ -0,0 +1,455 @@
1
+ import {
2
+ DownloadSimpleIcon,
3
+ PauseIcon,
4
+ PlayIcon,
5
+ XIcon,
6
+ } from '@phosphor-icons/react'
7
+ import classNames from 'classnames'
8
+ import React from 'react'
9
+ import {
10
+ displayDuration,
11
+ resampleWaveformData,
12
+ useAudioPlayer,
13
+ useStateStore,
14
+ type AudioPlayerState,
15
+ } from 'stream-chat-react'
16
+
17
+ import { triggerDownload } from '../_shared/triggerDownload'
18
+ import { useIsClient } from '../_shared/useIsClient'
19
+ import type { AudioItem, BubbleVariant } from '../types'
20
+
21
+ /**
22
+ * Bar count in the design's waveform (Figma `2754:17710`, nodes
23
+ * `…;2754:8066` – `…;2754:8100`). The 138px track is exactly
24
+ * `35 × 2px bars + 34 × 2px gaps`, so the count is derived from the
25
+ * drawn geometry rather than picked.
26
+ */
27
+ const WAVEFORM_BAR_COUNT = 35
28
+
29
+ /**
30
+ * Total fallback for every case where no amplitudes exist: a decode
31
+ * failure, an unsupported codec, a re-staged zero-byte placeholder, a
32
+ * file over the composer's decode ceiling, or any attachment sent
33
+ * before `waveform_data` was persisted (legacy web, the mobile app, a
34
+ * visitor).
35
+ *
36
+ * `.mes-audio-wave__bar` floors every bar at the design's 4px minimum,
37
+ * so all 35 bars render as the shortest bar the design already draws:
38
+ * the card's silhouette, height and geometry are identical to a real
39
+ * waveform, and the progress fill still sweeps left to right during
40
+ * playback. A shape-changing degraded state (e.g. a plain progress
41
+ * bar) would read as a different feature.
42
+ */
43
+ const FLAT_WAVEFORM: readonly number[] = Object.freeze(
44
+ new Array<number>(WAVEFORM_BAR_COUNT).fill(0)
45
+ )
46
+
47
+ /** Shown in the fixed duration slot until a duration is known. */
48
+ const UNKNOWN_DURATION = '--:--'
49
+
50
+ /**
51
+ * `displayDuration` splits the total into hour / minute buckets and only
52
+ * then `Math.ceil`s the seconds left over, so any fractional value in
53
+ * the last second of a minute formats as a sixtieth second: 59.5 renders
54
+ * `00:60`, and a playing clip flashes it at every minute boundary.
55
+ * Durations off an attachment and `secondsElapsed` off the player are
56
+ * both routinely fractional, so every call site floors first.
57
+ *
58
+ * Flooring (rather than rounding) is what the native `<audio>` element
59
+ * this card replaces did with `currentTime`: a clip reads `00:59` until
60
+ * the full minute has actually elapsed (MES-1360).
61
+ */
62
+ const formatSeconds = (seconds: number): string =>
63
+ displayDuration(Math.floor(seconds))
64
+
65
+ const AUDIO_PLAYER_STATE_SELECTOR = (state: AudioPlayerState) => ({
66
+ canPlayRecord: state.canPlayRecord,
67
+ isPlaying: state.isPlaying,
68
+ progressPercent: state.progressPercent,
69
+ secondsElapsed: state.secondsElapsed,
70
+ })
71
+
72
+ // Both of Figma's `content-control-buttons` instances (`…;2754:8063`
73
+ // play, `…;2754:8102` trailing) are the same 32×32 pill with a 6px inset
74
+ // around a 12px glyph and a 20px background blur; only the fill differs.
75
+ const CONTROL_CLASS =
76
+ 'flex size-8 shrink-0 items-center justify-center rounded-full backdrop-blur-[20px]'
77
+
78
+ // Figma draws only the dark sender card (`fill:#1E2330`), with every
79
+ // control in white. The light (`Received`) variant — the one visitors see
80
+ // on linktr.ee-profiles — is undrawn, so it inverts the same treatment
81
+ // against the `#f2f1ef` bubble. Ratios below were measured off the
82
+ // composited render, not estimated:
83
+ //
84
+ // played unplayed played:unplayed play button
85
+ // dark on #1E2330 15.69 3.71 4.24 15.69
86
+ // light on #F2F1EF 18.17 3.78 4.81 15.16
87
+ //
88
+ // Both unplayed fills clear WCAG 1.4.11's 3:1 floor for non-text contrast,
89
+ // and the played/unplayed step is well past it so progress is legible on
90
+ // its own. The light variant needs `#040403` at 0.5 where dark needs white
91
+ // at 0.4, because it composites towards a lighter backdrop.
92
+ const PLAY_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
93
+ dark: 'bg-white text-black/90 disabled:bg-white/40',
94
+ light: 'bg-[#040403]/90 text-white disabled:bg-[#040403]/30',
95
+ }
96
+
97
+ const TRAILING_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
98
+ dark: 'bg-[#040403]/80 text-white hover:bg-[#040403]',
99
+ light: 'bg-black/10 text-black/90 hover:bg-black/20',
100
+ }
101
+
102
+ /**
103
+ * What the design's trailing 32px control does. The ticket asks to
104
+ * "keep the same options as current production", and production has no
105
+ * product-owned menu on an audio attachment — the only menu was the
106
+ * native `<audio controls>` kebab, which disappears with the native
107
+ * player. So each surface carries forward the single action it already
108
+ * had: the composer removes the draft, and a sent / received card
109
+ * downloads the file (the capability the browser kebab provided).
110
+ */
111
+ export type AudioRowTrailingAction =
112
+ { kind: 'dismiss'; onDismiss: () => void } | { kind: 'download' }
113
+
114
+ export interface WaveformAudioRowProps {
115
+ item: AudioItem
116
+ variant: BubbleVariant
117
+ /**
118
+ * Omitted when a surface has no action to offer — a composer draft
119
+ * mounted without `onDismiss`. The row then renders no trailing
120
+ * control at all rather than falling through to the sent-only
121
+ * download, which is what the native player it replaces did.
122
+ */
123
+ trailingAction?: AudioRowTrailingAction
124
+ /**
125
+ * Namespaces the pooled `AudioPlayer`. Stream keys a pooled player by
126
+ * requester + source URL alone, so this has to be unique per rendered
127
+ * row, not just per message: two rows carrying the same `src` — a
128
+ * stacked bubble, or several cards in one story — otherwise share one
129
+ * player and move together. See `audioRowRequester`.
130
+ */
131
+ requester?: string
132
+ }
133
+
134
+ /**
135
+ * Resolves the track length. `durationSeconds` (persisted on the Stream
136
+ * attachment at send time) is authoritative and correct on first paint.
137
+ * Without it — legacy sends, mobile-app sends, visitor sends — a single
138
+ * detached `preload="metadata"` element resolves it from the response
139
+ * headers, which is exactly what the native player it replaces did.
140
+ */
141
+ const useResolvedDuration = (
142
+ src: string,
143
+ durationSeconds?: number
144
+ ): number | undefined => {
145
+ const [probed, setProbed] = React.useState<number>()
146
+
147
+ React.useEffect(() => {
148
+ if (durationSeconds != null || !src) return
149
+
150
+ const probe = document.createElement('audio')
151
+ probe.preload = 'metadata'
152
+ const handleLoadedMetadata = () => {
153
+ if (Number.isFinite(probe.duration) && probe.duration > 0) {
154
+ setProbed(probe.duration)
155
+ }
156
+ }
157
+ probe.addEventListener('loadedmetadata', handleLoadedMetadata)
158
+ probe.src = src
159
+
160
+ return () => {
161
+ probe.removeEventListener('loadedmetadata', handleLoadedMetadata)
162
+ probe.removeAttribute('src')
163
+ setProbed(undefined)
164
+ }
165
+ }, [src, durationSeconds])
166
+
167
+ return durationSeconds ?? probed
168
+ }
169
+
170
+ /**
171
+ * One waveform player row: 32px play toggle, 35-bar waveform with a
172
+ * progress fill and a native seek control, the duration, and the 32px
173
+ * trailing action. A single-item bubble is the design's 299×72 card;
174
+ * a stacked bubble repeats the row.
175
+ *
176
+ * Playback is `stream-chat-react`'s pooled `AudioPlayer`, which gives
177
+ * single-player-at-a-time across the thread, pause-on-unmount and
178
+ * saved-position restore. That pool lives on `<Channel>`, so outside
179
+ * one the card still renders in full with its controls disabled rather
180
+ * than vanishing.
181
+ *
182
+ * Split in two on purpose. Every `stream-chat-react` playback hook
183
+ * lives in `LiveAudioRow`, which mounts only in the browser:
184
+ * `useAudioPlayer` constructs the pooled player during render and its
185
+ * constructor evaluates `new Audio().canPlayType(mimeType)`, while
186
+ * `useStateStore` calls `useSyncExternalStore` with no
187
+ * `getServerSnapshot`. Both throw under Node, so a host that
188
+ * server-renders a channel — profiles is Next.js — got an exception
189
+ * instead of markup. `AudioRowView` carries the markup and renders with
190
+ * inert playback on the server, which is the same card the component
191
+ * already shows outside a `<Channel>` (MES-1360).
192
+ */
193
+ const WaveformAudioRow: React.FC<WaveformAudioRowProps> = (props) => {
194
+ const isClient = useIsClient()
195
+
196
+ return isClient ? (
197
+ <LiveAudioRow {...props} />
198
+ ) : (
199
+ <AudioRowView {...props} playback={INERT_PLAYBACK} />
200
+ )
201
+ }
202
+
203
+ /** Everything the view needs from a playback source, real or not. */
204
+ interface RowPlayback {
205
+ playable: boolean
206
+ showPause: boolean
207
+ isStarting: boolean
208
+ /** 0–100. */
209
+ progress: number
210
+ secondsElapsed: number
211
+ /**
212
+ * Resolved by the live row, which may have probed for it. The view
213
+ * falls back to the attachment's own value, which is what the server
214
+ * render has.
215
+ */
216
+ durationSeconds?: number
217
+ trackRef?: React.Ref<HTMLDivElement>
218
+ onTogglePlay?: () => void
219
+ onSeek?: (event: React.ChangeEvent<HTMLInputElement>) => void
220
+ }
221
+
222
+ /**
223
+ * A card with no player behind it: what the server renders, and what a
224
+ * card mounted outside `<Channel>` degrades to.
225
+ */
226
+ const INERT_PLAYBACK: RowPlayback = {
227
+ playable: false,
228
+ showPause: false,
229
+ isStarting: false,
230
+ progress: 0,
231
+ secondsElapsed: 0,
232
+ }
233
+
234
+ const LiveAudioRow: React.FC<WaveformAudioRowProps> = (props) => {
235
+ const { item, requester } = props
236
+ const trackRef = React.useRef<HTMLDivElement>(null)
237
+ const [isStarting, setIsStarting] = React.useState(false)
238
+ const durationSeconds = useResolvedDuration(item.src, item.durationSeconds)
239
+
240
+ const audioPlayer = useAudioPlayer({
241
+ durationSeconds,
242
+ mimeType: item.mimeType,
243
+ requester,
244
+ src: item.src,
245
+ title: item.filename,
246
+ })
247
+
248
+ const {
249
+ canPlayRecord = false,
250
+ isPlaying = false,
251
+ progressPercent = 0,
252
+ secondsElapsed = 0,
253
+ } = useStateStore(audioPlayer?.state, AUDIO_PLAYER_STATE_SELECTOR) ?? {}
254
+
255
+ const handleTogglePlay = () => {
256
+ if (!audioPlayer || isStarting) return
257
+ setIsStarting(true)
258
+ void audioPlayer.togglePlay().finally(() => setIsStarting(false))
259
+ }
260
+
261
+ // One seek code path for pointer, keyboard and drag: the native range
262
+ // input owns the interaction, and its value is translated into the
263
+ // `SeekFn` contract from the bar track's own rect. Going through
264
+ // `AudioPlayer.seek` inherits its lazy element acquisition (so
265
+ // seeking before the first play works), its seekable-range check and
266
+ // its 16ms throttle — `setSecondsElapsed` alone would not, because
267
+ // no `<audio>` element exists until the pool hands one over.
268
+ const handleSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
269
+ const track = trackRef.current
270
+ if (!audioPlayer || !track) return
271
+ const ratio = Math.min(Math.max(Number(event.target.value) / 100, 0), 1)
272
+ const rect = track.getBoundingClientRect()
273
+ void audioPlayer.seek({
274
+ clientX: rect.x + ratio * rect.width,
275
+ currentTarget: track,
276
+ })
277
+ }
278
+
279
+ return (
280
+ <AudioRowView
281
+ {...props}
282
+ playback={{
283
+ playable: !!audioPlayer && canPlayRecord,
284
+ // Strictly `isPlaying`. Flipping to Pause on `isStarting` gave a
285
+ // slow load a Pause control that looked live but swallowed every
286
+ // press, because `handleTogglePlay` returns early while a toggle
287
+ // is in flight — and assistive tech announced "Pause audio" for
288
+ // it. The start is advertised through `aria-busy` instead
289
+ // (MES-1360).
290
+ showPause: isPlaying,
291
+ isStarting,
292
+ progress: Math.min(Math.max(progressPercent, 0), 100),
293
+ secondsElapsed,
294
+ durationSeconds,
295
+ trackRef,
296
+ onTogglePlay: handleTogglePlay,
297
+ onSeek: handleSeek,
298
+ }}
299
+ />
300
+ )
301
+ }
302
+
303
+ /**
304
+ * The card's markup, with no playback source of its own. Rendered
305
+ * directly (with `INERT_PLAYBACK`) wherever Stream's hooks can't run.
306
+ */
307
+ const AudioRowView: React.FC<
308
+ WaveformAudioRowProps & { playback: RowPlayback }
309
+ > = ({ item, variant, trailingAction, playback }) => {
310
+ const {
311
+ isStarting,
312
+ onSeek,
313
+ onTogglePlay,
314
+ playable,
315
+ progress,
316
+ secondsElapsed,
317
+ showPause,
318
+ trackRef,
319
+ } = playback
320
+
321
+ // An empty array must never reach `resampleWaveformData` — its
322
+ // `upSample` branch `console.warn`s and returns `[]`, which renders no
323
+ // bars at all (and trips consumers whose tests fail on console output).
324
+ const amplitudes = React.useMemo<readonly number[]>(() => {
325
+ const data = item.waveformData
326
+ if (!data || data.length === 0) return FLAT_WAVEFORM
327
+ if (data.length === WAVEFORM_BAR_COUNT) return data
328
+ return resampleWaveformData(data, WAVEFORM_BAR_COUNT)
329
+ }, [item.waveformData])
330
+
331
+ const durationSeconds = playback.durationSeconds ?? item.durationSeconds
332
+ const totalLabel =
333
+ durationSeconds == null ? UNKNOWN_DURATION : formatSeconds(durationSeconds)
334
+ // Mirrors the native player: the slot counts up while playing and
335
+ // holds the elapsed position when paused, falling back to the total.
336
+ const displayedLabel =
337
+ secondsElapsed > 0 ? formatSeconds(secondsElapsed) : totalLabel
338
+
339
+ // Suffixed onto each control's accessible name so a thread of stacked
340
+ // clips reads as "Play audio — take-2.mp3", not three identical labels.
341
+ const label = item.filename ? ` \u2014 ${item.filename}` : ''
342
+
343
+ return (
344
+ <div
345
+ role="group"
346
+ aria-label={item.filename ?? 'Audio attachment'}
347
+ className="flex items-center"
348
+ data-testid="audio-attachment-row"
349
+ >
350
+ <button
351
+ type="button"
352
+ onClick={onTogglePlay}
353
+ disabled={!playable}
354
+ aria-busy={isStarting || undefined}
355
+ aria-label={`${showPause ? 'Pause audio' : 'Play audio'}${label}`}
356
+ className={classNames(CONTROL_CLASS, PLAY_BUTTON_BY_VARIANT[variant])}
357
+ >
358
+ {showPause ? (
359
+ <PauseIcon className="size-3" weight="fill" aria-hidden />
360
+ ) : (
361
+ <PlayIcon className="size-3" weight="fill" aria-hidden />
362
+ )}
363
+ </button>
364
+
365
+ {/* Figma `soundwave-container` (…;2754:8064): 203×30, 10px gap,
366
+ 12px horizontal inset, waveform flexing against a fixed
367
+ duration slot. */}
368
+ <div className="flex min-w-0 flex-1 items-center gap-2.5 px-3">
369
+ <div
370
+ className={classNames(
371
+ 'mes-audio-wave',
372
+ variant === 'light' && 'mes-audio-wave--light'
373
+ )}
374
+ >
375
+ {/* The bars carry no information a screen reader can use —
376
+ elapsed / total is announced by the range input below —
377
+ so they stay out of the accessibility tree entirely. */}
378
+ <div
379
+ ref={trackRef}
380
+ className="mes-audio-wave__track"
381
+ aria-hidden
382
+ data-testid="audio-waveform-track"
383
+ >
384
+ {amplitudes.map((amplitude, index) => (
385
+ <span
386
+ // Bars are positional, and the array is a fixed-length
387
+ // resample of the same track — index is the identity.
388
+ key={index}
389
+ data-testid="amplitude-bar"
390
+ className={classNames(
391
+ 'mes-audio-wave__bar',
392
+ progress > (index / amplitudes.length) * 100 &&
393
+ 'mes-audio-wave__bar--played'
394
+ )}
395
+ style={
396
+ {
397
+ '--mes-audio-wave-bar': `${
398
+ Math.min(Math.max(amplitude, 0), 1) * 100
399
+ }%`,
400
+ } as React.CSSProperties
401
+ }
402
+ />
403
+ ))}
404
+ </div>
405
+ <input
406
+ type="range"
407
+ className="mes-audio-wave__seek"
408
+ min={0}
409
+ max={100}
410
+ step={0.1}
411
+ value={progress}
412
+ onChange={onSeek}
413
+ disabled={!playable}
414
+ aria-label={`Seek audio${label}`}
415
+ aria-valuetext={`${formatSeconds(secondsElapsed)} of ${totalLabel}`}
416
+ />
417
+ </div>
418
+
419
+ <span className="shrink-0 text-xs leading-4 tracking-[0.24px] tabular-nums">
420
+ {displayedLabel}
421
+ </span>
422
+ </div>
423
+
424
+ {trailingAction ? (
425
+ <button
426
+ type="button"
427
+ onClick={
428
+ trailingAction.kind === 'dismiss'
429
+ ? trailingAction.onDismiss
430
+ : () => {
431
+ void triggerDownload(item.src, item.filename)
432
+ }
433
+ }
434
+ aria-label={
435
+ trailingAction.kind === 'dismiss'
436
+ ? 'Remove attachment'
437
+ : `Download audio${label}`
438
+ }
439
+ className={classNames(
440
+ CONTROL_CLASS,
441
+ TRAILING_BUTTON_BY_VARIANT[variant]
442
+ )}
443
+ >
444
+ {trailingAction.kind === 'dismiss' ? (
445
+ <XIcon className="size-3" weight="bold" aria-hidden />
446
+ ) : (
447
+ <DownloadSimpleIcon className="size-3" weight="bold" aria-hidden />
448
+ )}
449
+ </button>
450
+ ) : null}
451
+ </div>
452
+ )
453
+ }
454
+
455
+ export default WaveformAudioRow
@@ -0,0 +1,24 @@
1
+ import { describe, expect, it } from 'vitest'
2
+
3
+ import { audioRowRequester } from './audioRowRequester'
4
+
5
+ describe('audioRowRequester', () => {
6
+ it('gives each row in a message its own namespace', () => {
7
+ // Stream keys a pooled player by requester + src, so rows sharing a
8
+ // src under one message id would otherwise be the same player and
9
+ // move together.
10
+ expect(audioRowRequester('msg-1', 0)).not.toBe(
11
+ audioRowRequester('msg-1', 1)
12
+ )
13
+ })
14
+
15
+ it('keeps separate messages apart', () => {
16
+ expect(audioRowRequester('msg-1', 0)).not.toBe(
17
+ audioRowRequester('msg-2', 0)
18
+ )
19
+ })
20
+
21
+ it('is stable for the same row, so a remount restores its position', () => {
22
+ expect(audioRowRequester('msg-1', 2)).toBe(audioRowRequester('msg-1', 2))
23
+ })
24
+ })
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Namespace for one rendered audio row's pooled player.
3
+ *
4
+ * Stream keys a pooled `AudioPlayer` by requester + source URL alone
5
+ * (`makeAudioPlayerId`), so two rows showing the same asset under the
6
+ * same requester resolve to a single player and share play state,
7
+ * progress and elapsed time. That is reachable today: a stacked bubble
8
+ * repeating a clip, several cards for one attachment, or the `Single`
9
+ * story, which renders the same URL three times.
10
+ *
11
+ * The row index disambiguates them and is stable across re-renders and
12
+ * remounts, so a message-scoped base still restores its saved position
13
+ * when a virtualized thread scrolls the row away and back (MES-1360).
14
+ */
15
+ export const audioRowRequester = (base: string, index: number): string =>
16
+ `${base}:${index}`