@linktr.ee/messaging-react 4.1.5 → 4.2.0-rc-1787083791

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.
@@ -0,0 +1,335 @@
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 type { AudioItem, BubbleVariant } from '../types'
19
+
20
+ /**
21
+ * Bar count in the design's waveform (Figma `2754:17710`, nodes
22
+ * `…;2754:8066` – `…;2754:8100`). The 138px track is exactly
23
+ * `35 × 2px bars + 34 × 2px gaps`, so the count is derived from the
24
+ * drawn geometry rather than picked.
25
+ */
26
+ const WAVEFORM_BAR_COUNT = 35
27
+
28
+ /**
29
+ * Total fallback for every case where no amplitudes exist: a decode
30
+ * failure, an unsupported codec, a re-staged zero-byte placeholder, a
31
+ * file over the composer's decode ceiling, or any attachment sent
32
+ * before `waveform_data` was persisted (legacy web, the mobile app, a
33
+ * visitor).
34
+ *
35
+ * `.mes-audio-wave__bar` floors every bar at the design's 4px minimum,
36
+ * so all 35 bars render as the shortest bar the design already draws:
37
+ * the card's silhouette, height and geometry are identical to a real
38
+ * waveform, and the progress fill still sweeps left to right during
39
+ * playback. A shape-changing degraded state (e.g. a plain progress
40
+ * bar) would read as a different feature.
41
+ */
42
+ const FLAT_WAVEFORM: readonly number[] = Object.freeze(
43
+ new Array<number>(WAVEFORM_BAR_COUNT).fill(0)
44
+ )
45
+
46
+ /** Shown in the fixed duration slot until a duration is known. */
47
+ const UNKNOWN_DURATION = '--:--'
48
+
49
+ const AUDIO_PLAYER_STATE_SELECTOR = (state: AudioPlayerState) => ({
50
+ canPlayRecord: state.canPlayRecord,
51
+ isPlaying: state.isPlaying,
52
+ progressPercent: state.progressPercent,
53
+ secondsElapsed: state.secondsElapsed,
54
+ })
55
+
56
+ // Both of Figma's `content-control-buttons` instances (`…;2754:8063`
57
+ // play, `…;2754:8102` trailing) are the same 32×32 pill with a 6px inset
58
+ // around a 12px glyph and a 20px background blur; only the fill differs.
59
+ const CONTROL_CLASS =
60
+ 'flex size-8 shrink-0 items-center justify-center rounded-full backdrop-blur-[20px]'
61
+
62
+ // Figma draws only the dark sender card (`fill:#1E2330`), with every
63
+ // control in white. The light (`Received`) variant — the one visitors see
64
+ // on linktr.ee-profiles — is undrawn, so it inverts the same treatment
65
+ // against the `#f2f1ef` bubble. Ratios below were measured off the
66
+ // composited render, not estimated:
67
+ //
68
+ // played unplayed played:unplayed play button
69
+ // dark on #1E2330 15.69 3.71 4.24 15.69
70
+ // light on #F2F1EF 18.17 3.78 4.81 15.16
71
+ //
72
+ // Both unplayed fills clear WCAG 1.4.11's 3:1 floor for non-text contrast,
73
+ // and the played/unplayed step is well past it so progress is legible on
74
+ // its own. The light variant needs `#040403` at 0.5 where dark needs white
75
+ // at 0.4, because it composites towards a lighter backdrop.
76
+ const PLAY_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
77
+ dark: 'bg-white text-black/90 disabled:bg-white/40',
78
+ light: 'bg-[#040403]/90 text-white disabled:bg-[#040403]/30',
79
+ }
80
+
81
+ const TRAILING_BUTTON_BY_VARIANT: Record<BubbleVariant, string> = {
82
+ dark: 'bg-[#040403]/80 text-white hover:bg-[#040403]',
83
+ light: 'bg-black/10 text-black/90 hover:bg-black/20',
84
+ }
85
+
86
+ /**
87
+ * What the design's trailing 32px control does. The ticket asks to
88
+ * "keep the same options as current production", and production has no
89
+ * product-owned menu on an audio attachment — the only menu was the
90
+ * native `<audio controls>` kebab, which disappears with the native
91
+ * player. So each surface carries forward the single action it already
92
+ * had: the composer removes the draft, and a sent / received card
93
+ * downloads the file (the capability the browser kebab provided).
94
+ */
95
+ export type AudioRowTrailingAction =
96
+ | { kind: 'dismiss'; onDismiss: () => void }
97
+ | { kind: 'download' }
98
+
99
+ export interface WaveformAudioRowProps {
100
+ item: AudioItem
101
+ variant: BubbleVariant
102
+ trailingAction: AudioRowTrailingAction
103
+ /**
104
+ * Namespaces the pooled `AudioPlayer` so the same asset rendered in
105
+ * two different messages keeps two independent playback positions.
106
+ * Derived from the Stream message id where one is available.
107
+ */
108
+ requester?: string
109
+ }
110
+
111
+ /**
112
+ * Resolves the track length. `durationSeconds` (persisted on the Stream
113
+ * attachment at send time) is authoritative and correct on first paint.
114
+ * Without it — legacy sends, mobile-app sends, visitor sends — a single
115
+ * detached `preload="metadata"` element resolves it from the response
116
+ * headers, which is exactly what the native player it replaces did.
117
+ */
118
+ const useResolvedDuration = (
119
+ src: string,
120
+ durationSeconds?: number
121
+ ): number | undefined => {
122
+ const [probed, setProbed] = React.useState<number>()
123
+
124
+ React.useEffect(() => {
125
+ if (durationSeconds != null || !src) return
126
+
127
+ const probe = document.createElement('audio')
128
+ probe.preload = 'metadata'
129
+ const handleLoadedMetadata = () => {
130
+ if (Number.isFinite(probe.duration) && probe.duration > 0) {
131
+ setProbed(probe.duration)
132
+ }
133
+ }
134
+ probe.addEventListener('loadedmetadata', handleLoadedMetadata)
135
+ probe.src = src
136
+
137
+ return () => {
138
+ probe.removeEventListener('loadedmetadata', handleLoadedMetadata)
139
+ probe.removeAttribute('src')
140
+ setProbed(undefined)
141
+ }
142
+ }, [src, durationSeconds])
143
+
144
+ return durationSeconds ?? probed
145
+ }
146
+
147
+ /**
148
+ * One waveform player row: 32px play toggle, 35-bar waveform with a
149
+ * progress fill and a native seek control, the duration, and the 32px
150
+ * trailing action. A single-item bubble is the design's 299×72 card;
151
+ * a stacked bubble repeats the row.
152
+ *
153
+ * Playback is `stream-chat-react`'s pooled `AudioPlayer`, which gives
154
+ * single-player-at-a-time across the thread, pause-on-unmount and
155
+ * saved-position restore. That pool lives on `<Channel>`, so outside
156
+ * one the card still renders in full with its controls disabled rather
157
+ * than vanishing.
158
+ */
159
+ const WaveformAudioRow: React.FC<WaveformAudioRowProps> = ({
160
+ item,
161
+ variant,
162
+ trailingAction,
163
+ requester,
164
+ }) => {
165
+ const trackRef = React.useRef<HTMLDivElement>(null)
166
+ const [isStarting, setIsStarting] = React.useState(false)
167
+
168
+ // An empty array must never reach `resampleWaveformData` — its
169
+ // `upSample` branch `console.warn`s and returns `[]`, which renders no
170
+ // bars at all (and trips consumers whose tests fail on console output).
171
+ const amplitudes = React.useMemo<readonly number[]>(() => {
172
+ const data = item.waveformData
173
+ if (!data || data.length === 0) return FLAT_WAVEFORM
174
+ if (data.length === WAVEFORM_BAR_COUNT) return data
175
+ return resampleWaveformData(data, WAVEFORM_BAR_COUNT)
176
+ }, [item.waveformData])
177
+ const durationSeconds = useResolvedDuration(item.src, item.durationSeconds)
178
+
179
+ const audioPlayer = useAudioPlayer({
180
+ durationSeconds,
181
+ mimeType: item.mimeType,
182
+ requester,
183
+ src: item.src,
184
+ title: item.filename,
185
+ })
186
+
187
+ const {
188
+ canPlayRecord = false,
189
+ isPlaying = false,
190
+ progressPercent = 0,
191
+ secondsElapsed = 0,
192
+ } = useStateStore(audioPlayer?.state, AUDIO_PLAYER_STATE_SELECTOR) ?? {}
193
+
194
+ const progress = Math.min(Math.max(progressPercent, 0), 100)
195
+ const playable = !!audioPlayer && canPlayRecord
196
+ const showPause = isPlaying || isStarting
197
+
198
+ const totalLabel =
199
+ durationSeconds == null ? UNKNOWN_DURATION : displayDuration(durationSeconds)
200
+ // Mirrors the native player: the slot counts up while playing and
201
+ // holds the elapsed position when paused, falling back to the total.
202
+ const displayedLabel =
203
+ secondsElapsed > 0 ? displayDuration(secondsElapsed) : totalLabel
204
+
205
+ const handleTogglePlay = () => {
206
+ if (!audioPlayer || isStarting) return
207
+ setIsStarting(true)
208
+ void audioPlayer.togglePlay().finally(() => setIsStarting(false))
209
+ }
210
+
211
+ // One seek code path for pointer, keyboard and drag: the native range
212
+ // input owns the interaction, and its value is translated into the
213
+ // `SeekFn` contract from the bar track's own rect. Going through
214
+ // `AudioPlayer.seek` inherits its lazy element acquisition (so
215
+ // seeking before the first play works), its seekable-range check and
216
+ // its 16ms throttle — `setSecondsElapsed` alone would not, because
217
+ // no `<audio>` element exists until the pool hands one over.
218
+ const handleSeek = (event: React.ChangeEvent<HTMLInputElement>) => {
219
+ const track = trackRef.current
220
+ if (!audioPlayer || !track) return
221
+ const ratio = Math.min(Math.max(Number(event.target.value) / 100, 0), 1)
222
+ const rect = track.getBoundingClientRect()
223
+ void audioPlayer.seek({
224
+ clientX: rect.x + ratio * rect.width,
225
+ currentTarget: track,
226
+ })
227
+ }
228
+
229
+ // Suffixed onto each control's accessible name so a thread of stacked
230
+ // clips reads as "Play audio — take-2.mp3", not three identical labels.
231
+ const label = item.filename ? ` \u2014 ${item.filename}` : ''
232
+
233
+ return (
234
+ <div
235
+ role="group"
236
+ aria-label={item.filename ?? 'Audio attachment'}
237
+ className="flex items-center"
238
+ data-testid="audio-attachment-row"
239
+ >
240
+ <button
241
+ type="button"
242
+ onClick={handleTogglePlay}
243
+ disabled={!playable}
244
+ aria-busy={isStarting || undefined}
245
+ aria-label={`${showPause ? 'Pause audio' : 'Play audio'}${label}`}
246
+ className={classNames(CONTROL_CLASS, PLAY_BUTTON_BY_VARIANT[variant])}
247
+ >
248
+ {showPause ? (
249
+ <PauseIcon className="size-3" weight="fill" aria-hidden />
250
+ ) : (
251
+ <PlayIcon className="size-3" weight="fill" aria-hidden />
252
+ )}
253
+ </button>
254
+
255
+ {/* Figma `soundwave-container` (…;2754:8064): 203×30, 10px gap,
256
+ 12px horizontal inset, waveform flexing against a fixed
257
+ duration slot. */}
258
+ <div className="flex min-w-0 flex-1 items-center gap-2.5 px-3">
259
+ <div
260
+ className={classNames(
261
+ 'mes-audio-wave',
262
+ variant === 'light' && 'mes-audio-wave--light'
263
+ )}
264
+ >
265
+ {/* The bars carry no information a screen reader can use —
266
+ elapsed / total is announced by the range input below —
267
+ so they stay out of the accessibility tree entirely. */}
268
+ <div ref={trackRef} className="mes-audio-wave__track" aria-hidden>
269
+ {amplitudes.map((amplitude, index) => (
270
+ <span
271
+ // Bars are positional, and the array is a fixed-length
272
+ // resample of the same track — index is the identity.
273
+ key={index}
274
+ data-testid="amplitude-bar"
275
+ className={classNames(
276
+ 'mes-audio-wave__bar',
277
+ progress > (index / amplitudes.length) * 100 &&
278
+ 'mes-audio-wave__bar--played'
279
+ )}
280
+ style={
281
+ {
282
+ '--mes-audio-wave-bar': `${
283
+ Math.min(Math.max(amplitude, 0), 1) * 100
284
+ }%`,
285
+ } as React.CSSProperties
286
+ }
287
+ />
288
+ ))}
289
+ </div>
290
+ <input
291
+ type="range"
292
+ className="mes-audio-wave__seek"
293
+ min={0}
294
+ max={100}
295
+ step={0.1}
296
+ value={progress}
297
+ onChange={handleSeek}
298
+ disabled={!playable}
299
+ aria-label={`Seek audio${label}`}
300
+ aria-valuetext={`${displayDuration(secondsElapsed)} of ${totalLabel}`}
301
+ />
302
+ </div>
303
+
304
+ <span className="shrink-0 text-xs leading-4 tracking-[0.24px] tabular-nums">
305
+ {displayedLabel}
306
+ </span>
307
+ </div>
308
+
309
+ <button
310
+ type="button"
311
+ onClick={
312
+ trailingAction.kind === 'dismiss'
313
+ ? trailingAction.onDismiss
314
+ : () => {
315
+ void triggerDownload(item.src, item.filename)
316
+ }
317
+ }
318
+ aria-label={
319
+ trailingAction.kind === 'dismiss'
320
+ ? 'Remove attachment'
321
+ : `Download audio${label}`
322
+ }
323
+ className={classNames(CONTROL_CLASS, TRAILING_BUTTON_BY_VARIANT[variant])}
324
+ >
325
+ {trailingAction.kind === 'dismiss' ? (
326
+ <XIcon className="size-3" weight="bold" aria-hidden />
327
+ ) : (
328
+ <DownloadSimpleIcon className="size-3" weight="bold" aria-hidden />
329
+ )}
330
+ </button>
331
+ </div>
332
+ )
333
+ }
334
+
335
+ export default WaveformAudioRow
@@ -1,93 +1,74 @@
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 WaveformAudioRow, {
14
+ type AudioRowTrailingAction,
15
+ } from './WaveformAudioRow'
16
+
17
+ // Figma `2754:17710` → `attachment-audio` (`…;2754:8062`): a 299×72
18
+ // card on `#1E2330` with a 20/12/20/20 inset, a uniform 20px radius and
19
+ // no stroke. `@linktr.ee/component-library@11.8.6`'s radius scale is
20
+ // 8 / 16 / 24 / 64 / full, so there is no 20px token to reach for.
21
+ //
22
+ // The radius is uniform in every state, so the card opts out of the
23
+ // same-author corner flattening the text bubbles apply — a 32px control
24
+ // tucked into a 4px corner would collide with it.
25
+ const CARD_WIDTH_CLASS = 'w-[299px]'
26
+ const CARD_PADDING_CLASS = 'py-5 pl-5 pr-3'
27
+ const CARD_RADIUS_CLASS = 'rounded-[20px]'
28
+
14
29
  export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
15
30
  /** Audio source URL (`mp3`, `aac`, `wav`, …). */
16
31
  src?: string
17
- /** MIME type hint — typed onto the inline `<source>` element. */
32
+ /** MIME type hint — gates playability via the player's `canPlayType` probe. */
18
33
  mimeType?: string
19
34
  /**
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.
35
+ * Filename — the download default name for the trailing action, and
36
+ * part of each control's accessible name.
24
37
  */
25
38
  filename?: string
26
39
  /**
27
40
  * 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.
41
+ * renders its own waveform row, vertically stacked inside the same
42
+ * bubble with an 8px gap. Sent + Received only — the composer surface
43
+ * accepts a single attachment at a time.
32
44
  */
33
45
  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
46
+ /** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
47
+ durationSeconds?: number
48
+ /** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
49
+ waveformData?: number[]
48
50
  }
49
51
 
50
52
  const resolveItems = ({
51
53
  src,
52
54
  mimeType,
53
55
  filename,
56
+ durationSeconds,
57
+ waveformData,
54
58
  items,
55
59
  }: {
56
60
  src?: string
57
61
  mimeType?: string
58
62
  filename?: string
63
+ durationSeconds?: number
64
+ waveformData?: number[]
59
65
  items?: AudioItem[]
60
66
  }): AudioItem[] => {
61
67
  if (items && items.length > 0) return items
62
- if (src) return [{ src, mimeType, filename }]
68
+ if (src) return [{ src, mimeType, filename, durationSeconds, waveformData }]
63
69
  return []
64
70
  }
65
71
 
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
72
  interface InternalAudioRowProps extends AudioAttachmentSharedProps {
92
73
  state: MessageAttachmentState
93
74
  onDismiss?: () => void
@@ -99,52 +80,63 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
99
80
  mimeType,
100
81
  filename,
101
82
  items,
83
+ durationSeconds,
84
+ waveformData,
102
85
  text,
103
86
  groupPosition,
104
- preload,
105
87
  onDismiss,
106
88
  }) => {
89
+ // Namespaces the pooled players per message, so scrolling a
90
+ // virtualized thread away and back restores the playback position
91
+ // instead of starting a fresh player. Returns `{}` (silently) outside
92
+ // a message, which is the composer case.
93
+ const { message, threadList } = useMessageContext()
94
+
107
95
  const variant = bubbleVariantForState(state)
108
- const showDismiss = state === 'composer' && !!onDismiss
109
- const resolvedItems = resolveItems({ src, mimeType, filename, items })
96
+ const resolvedItems = resolveItems({
97
+ src,
98
+ mimeType,
99
+ filename,
100
+ durationSeconds,
101
+ waveformData,
102
+ items,
103
+ })
110
104
 
111
105
  if (resolvedItems.length === 0) {
112
106
  return null
113
107
  }
114
108
 
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')
109
+ const requester = message?.id
110
+ ? `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`
111
+ : undefined
112
+
113
+ // The composer's single action is removing the draft; every other
114
+ // surface carries forward the download the native player's own kebab
115
+ // used to provide. See `AudioRowTrailingAction`.
116
+ const trailingAction: AudioRowTrailingAction =
117
+ state === 'composer' && onDismiss
118
+ ? { kind: 'dismiss', onDismiss }
119
+ : { kind: 'download' }
121
120
 
122
121
  return (
123
122
  <Bubble
124
123
  variant={variant}
125
124
  text={text}
126
125
  groupPosition={groupPosition}
126
+ bordered={false}
127
+ widthClassName={CARD_WIDTH_CLASS}
128
+ paddingClassName={CARD_PADDING_CLASS}
129
+ radiusClassName={CARD_RADIUS_CLASS}
127
130
  data-testid="audio-attachment"
128
131
  >
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
132
  <div className="flex flex-col gap-2">
136
133
  {resolvedItems.map((item, index) => (
137
- <NativeAudioPlayer
134
+ <WaveformAudioRow
138
135
  key={`${item.src}-${index}`}
139
136
  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
- }
137
+ variant={variant}
138
+ requester={requester}
139
+ trailingAction={trailingAction}
148
140
  />
149
141
  ))}
150
142
  </div>
@@ -164,8 +156,10 @@ export interface AudioComposerProps extends ComposerExtras {
164
156
  src?: string
165
157
  mimeType?: string
166
158
  filename?: string
167
- /** See `AudioAttachmentSharedProps.preload`. */
168
- preload?: MediaPreloadMode
159
+ /** See `AudioItem.durationSeconds`. */
160
+ durationSeconds?: number
161
+ /** See `AudioItem.waveformData`. */
162
+ waveformData?: number[]
169
163
  }
170
164
  export type AudioSentProps = AudioAttachmentSharedProps
171
165
  export type AudioReceivedProps = AudioAttachmentSharedProps