@linktr.ee/messaging-react 3.31.0-rc-1785485102 → 3.31.0

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 (44) hide show
  1. package/dist/{Card-2l_y20FM.js → Card-CmlaSw7i.js} +2 -2
  2. package/dist/{Card-2l_y20FM.js.map → Card-CmlaSw7i.js.map} +1 -1
  3. package/dist/{Card-Gr4HDVbn.cjs → Card-EWN07XIl.cjs} +2 -2
  4. package/dist/{Card-Gr4HDVbn.cjs.map → Card-EWN07XIl.cjs.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/{index-B96WLmx6.js → index-CN3errpB.js} +3055 -3109
  7. package/dist/index-CN3errpB.js.map +1 -0
  8. package/dist/index-CcitzVoW.cjs +5 -0
  9. package/dist/index-CcitzVoW.cjs.map +1 -0
  10. package/dist/index.cjs +1 -1
  11. package/dist/index.d.ts +56 -60
  12. package/dist/index.js +1 -1
  13. package/package.json +2 -2
  14. package/src/components/ChannelView.broadcast-name.integration.test.tsx +116 -0
  15. package/src/components/ChannelView.tsx +17 -3
  16. package/src/components/CustomLinkPreviewList/CustomLinkPreviewList.test.tsx +15 -0
  17. package/src/components/CustomLinkPreviewList/index.tsx +24 -26
  18. package/src/components/CustomMessage/BroadcastNameContext.test.ts +43 -0
  19. package/src/components/CustomMessage/BroadcastNameContext.ts +95 -0
  20. package/src/components/CustomMessage/CustomMessage.stories.tsx +54 -22
  21. package/src/components/CustomMessage/CustomMessage.test.tsx +48 -0
  22. package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +62 -0
  23. package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +8 -2
  24. package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
  25. package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -8
  26. package/src/components/CustomMessage/index.tsx +23 -3
  27. package/src/components/CustomMessageInput/CustomMessageInput.stories.tsx +50 -2
  28. package/src/components/CustomMessageInput/CustomMessageInput.test.tsx +180 -2
  29. package/src/components/CustomMessageInput/index.tsx +143 -16
  30. package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +57 -166
  31. package/src/components/MessageAttachment/Audio/index.tsx +77 -71
  32. package/src/components/MessageAttachment/MessageAttachment.test.tsx +75 -175
  33. package/src/components/MessageAttachment/index.tsx +9 -25
  34. package/src/components/MessageAttachment/types.ts +9 -19
  35. package/src/components/MessagingShell/index.tsx +2 -0
  36. package/src/index.ts +1 -0
  37. package/src/styles.css +5 -120
  38. package/src/types.ts +11 -0
  39. package/src/utils/formatRelativeTime.test.ts +63 -57
  40. package/src/utils/formatRelativeTime.ts +21 -20
  41. package/dist/index-B96WLmx6.js.map +0 -1
  42. package/dist/index-CCtHMrmO.cjs +0 -5
  43. package/dist/index-CCtHMrmO.cjs.map +0 -1
  44. package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +0 -335
@@ -1,9 +1,4 @@
1
1
  import React from 'react'
2
- import {
3
- ChatProvider,
4
- WithAudioPlayback,
5
- type ChatContextValue,
6
- } from 'stream-chat-react'
7
2
  import { beforeEach, describe, expect, it, vi } from 'vitest'
8
3
 
9
4
  import { renderWithProviders, screen, fireEvent } from '../../test/utils'
@@ -12,21 +7,6 @@ import { triggerDownload } from './_shared/triggerDownload'
12
7
 
13
8
  import MessageAttachment, { bubbleGroupPositionFromStream } from '.'
14
9
 
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
-
30
10
  vi.mock('./_shared/triggerDownload', () => ({
31
11
  triggerDownload: vi.fn(() => Promise.resolve()),
32
12
  }))
@@ -736,186 +716,52 @@ describe('MessageAttachment.File', () => {
736
716
  })
737
717
 
738
718
  describe('MessageAttachment.Audio', () => {
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(
719
+ it('renders the native audio player only (no title, no extra download)', () => {
720
+ renderWithProviders(
747
721
  <MessageAttachment.Audio.Sent
748
722
  src="https://cdn.example.com/song.mp3"
749
723
  mimeType="audio/mpeg"
750
724
  filename="song.mp3"
751
- durationSeconds={12}
752
- waveformData={WAVEFORM}
753
725
  />
754
726
  )
755
-
756
727
  const bubble = screen.getByTestId('audio-attachment')
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()
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.
762
734
  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
- })
794
- })
795
-
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
- )
735
+ screen.queryByRole('button', { name: /download/i })
736
+ ).toBeNull()
822
737
  })
823
738
 
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…
739
+ it('shows an inline dismiss button next to the player on Composer state', () => {
828
740
  const onDismiss = vi.fn()
829
- renderAudio(
741
+ renderWithProviders(
830
742
  <MessageAttachment.Audio.Composer
831
743
  src="https://cdn.example.com/song.mp3"
832
- filename="song.mp3"
833
744
  onDismiss={onDismiss}
834
745
  />
835
746
  )
836
-
837
- expect(
838
- screen.queryByRole('button', { name: /download/i })
839
- ).not.toBeInTheDocument()
840
- fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
747
+ const dismiss = screen.getByLabelText('Dismiss attachment')
748
+ expect(dismiss).toBeInTheDocument()
749
+ fireEvent.click(dismiss)
841
750
  expect(onDismiss).toHaveBeenCalledTimes(1)
842
751
  })
843
752
 
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
+ it('renders one native player per item for stacked audio', () => {
754
+ renderWithProviders(
865
755
  <MessageAttachment.Audio.Sent
866
756
  items={[
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 },
757
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
758
+ { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
759
+ { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
870
760
  ]}
871
761
  />
872
762
  )
873
-
874
763
  const bubble = screen.getByTestId('audio-attachment')
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()
764
+ expect(bubble.querySelectorAll('audio')).toHaveLength(3)
919
765
  })
920
766
  })
921
767
 
@@ -1107,6 +953,60 @@ describe('MessageAttachment lazy-loading defaults', () => {
1107
953
  expect(video?.getAttribute('preload')).toBe('metadata')
1108
954
  })
1109
955
  })
956
+
957
+ describe('Audio', () => {
958
+ it('defaults a single-track bubble to preload="metadata"', () => {
959
+ renderWithProviders(
960
+ <MessageAttachment.Audio.Received
961
+ src="https://cdn.example.com/song.mp3"
962
+ mimeType="audio/mpeg"
963
+ />
964
+ )
965
+ const audio = screen
966
+ .getByTestId('audio-attachment')
967
+ .querySelector('audio')
968
+ expect(audio?.getAttribute('preload')).toBe('metadata')
969
+ })
970
+
971
+ it('defaults a stacked (3-track) bubble to preload="none" on every player', () => {
972
+ renderWithProviders(
973
+ <MessageAttachment.Audio.Sent
974
+ items={[
975
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
976
+ { src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
977
+ { src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
978
+ ]}
979
+ />
980
+ )
981
+ const audios = Array.from(
982
+ screen.getByTestId('audio-attachment').querySelectorAll('audio')
983
+ )
984
+ expect(audios).toHaveLength(3)
985
+ audios.forEach((audio) => {
986
+ expect(audio.getAttribute('preload')).toBe('none')
987
+ })
988
+ })
989
+
990
+ it('honors a per-track AudioItem.preload override', () => {
991
+ renderWithProviders(
992
+ <MessageAttachment.Audio.Sent
993
+ items={[
994
+ { src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
995
+ {
996
+ src: 'https://cdn.example.com/b.mp3',
997
+ mimeType: 'audio/mpeg',
998
+ preload: 'metadata',
999
+ },
1000
+ ]}
1001
+ />
1002
+ )
1003
+ const audios = Array.from(
1004
+ screen.getByTestId('audio-attachment').querySelectorAll('audio')
1005
+ )
1006
+ expect(audios[0]?.getAttribute('preload')).toBe('none')
1007
+ expect(audios[1]?.getAttribute('preload')).toBe('metadata')
1008
+ })
1009
+ })
1110
1010
  })
1111
1011
 
1112
1012
  describe('bubbleGroupPositionFromStream', () => {
@@ -56,7 +56,7 @@ import VideoAttachment, {
56
56
  * - Pdf / Audio / File render their compact rows / players in a
57
57
  * vertical stack with an 8px gap between rows. Each row keeps
58
58
  * its own activation target — Pdf opens the carousel-aware
59
- * `PdfViewer` at that index, Audio gets its own waveform player,
59
+ * `PdfViewer` at that index, Audio gets its own native player,
60
60
  * and File downloads the asset for that row.
61
61
  *
62
62
  * The Composer surface accepts only a single attachment at a time,
@@ -74,29 +74,12 @@ import VideoAttachment, {
74
74
  * download button to the chrome; the Video viewer relies on the
75
75
  * native `<video controls>` download menu, and the Pdf viewer
76
76
  * inherits the browser's built-in PDF download. Sibling-row Download
77
- * buttons on Pdf / File and the Audio card's trailing action expose
77
+ * buttons on Pdf / File and the native `<audio>` controls expose
78
78
  * downloads outside the viewer too. The lightbox chrome is styled
79
79
  * with plain CSS (`.mes-media-viewer*`) so the package's shipped
80
80
  * stylesheet is enough to render the viewer correctly without a
81
81
  * Tailwind dependency in the consumer.
82
82
  *
83
- * # Audio requires a `<Channel>` ancestor
84
- *
85
- * `MessageAttachment.Audio.*` renders the waveform player card —
86
- * a 32px play toggle, a 35-bar waveform with a progress fill and a
87
- * native seek control, the duration, and a 32px trailing action
88
- * (Remove in the composer, Download once sent). Playback is
89
- * `stream-chat-react`'s pooled `AudioPlayer`, which is what supplies
90
- * single-player-at-a-time across a thread, pause-on-navigation and
91
- * saved-position restore across virtualized remounts.
92
- *
93
- * That pool is provided by `<Channel>`. Rendered outside one the card
94
- * still draws in full, but its play and seek controls are disabled —
95
- * so a story or a new consumer surface must mount `<Channel>` (or
96
- * `stream-chat-react`'s `WithAudioPlayback`) for the card to be
97
- * playable. The waveform bars themselves are styled with plain CSS
98
- * (`.mes-audio-wave*`), so the shipped stylesheet is enough.
99
- *
100
83
  * # Lazy-loading defaults
101
84
  *
102
85
  * Media attachments bake in network-friendly defaults for long chat
@@ -117,12 +100,13 @@ import VideoAttachment, {
117
100
  * `VideoItem.preload` per item). Once the viewer is open the
118
101
  * active item bumps to `preload="metadata"` so duration / the
119
102
  * first frame appear immediately.
120
- * - **Audio** — no audio bytes are fetched until the user presses
121
- * play: the pooled `AudioPlayer` acquires the shared `<audio>`
122
- * element lazily. A card whose `durationSeconds` is unknown makes
123
- * one `preload="metadata"` header request to fill the duration
124
- * slot; supplying `durationSeconds` (which `StreamAttachmentMessage`
125
- * maps from the attachment's persisted `duration`) avoids even that.
103
+ * - **Audio** — a single audio bubble defaults to
104
+ * `preload="metadata"` so the native player surfaces duration
105
+ * immediately. A stacked audio bubble (`items.length > 1`)
106
+ * defaults to `preload="none"` for every player so a thread of
107
+ * voice memos doesn't fan out N parallel metadata requests on
108
+ * first paint. Override via the attachment's `preload` prop
109
+ * (or `AudioItem.preload` per track).
126
110
  * - **Pdf** / **File** — no inline media element to throttle; the
127
111
  * bubble renders compact rows that download / open lazily on
128
112
  * activation. No `loading` / `preload` props on these surfaces.
@@ -119,31 +119,21 @@ export interface PdfItem {
119
119
  /** A single audio file inside an Audio attachment (single or stacked). */
120
120
  export interface AudioItem {
121
121
  src: string
122
- /** MIME type hint — gates playability via the player's `canPlayType` probe. */
122
+ /** MIME type hint — typed onto the inline `<source>` element. */
123
123
  mimeType?: string
124
124
  /**
125
- * Filename — the download default name for the card's trailing
126
- * action, and part of the card's accessible name.
125
+ * Filename — used as the download default name (consumed by the
126
+ * native player's kebab menu).
127
127
  */
128
128
  filename?: string
129
129
  /**
130
- * Track length in seconds. Rendered as `MM:SS` before playback
131
- * starts, so the duration is correct on first paint with no network
132
- * request. Sourced from the Stream attachment's `duration` field,
133
- * which the composer writes at send time. When omitted the card
134
- * shows `--:--` until a one-shot `loadedmetadata` probe resolves.
130
+ * Per-track override for the `<audio preload>` hint. Defaults to
131
+ * the parent attachment's `preload` value, which itself defaults
132
+ * to `'metadata'` for a single audio (so the native player can
133
+ * surface duration immediately) and `'none'` for a stacked thread
134
+ * (so a fan-out of metadata requests doesn't happen on first paint).
135
135
  */
136
- durationSeconds?: number
137
- /**
138
- * Waveform amplitudes, each `0..1`, in track order. Resampled to the
139
- * rendered bar count, so any length is accepted. Sourced from the
140
- * Stream attachment's `waveform_data`, which the composer computes
141
- * from the local file and writes at send time.
142
- *
143
- * When omitted, the card renders a flat placeholder row: every bar at
144
- * the minimum height, with the progress fill still tracking playback.
145
- */
146
- waveformData?: number[]
136
+ preload?: MediaPreloadMode
147
137
  }
148
138
 
149
139
  /** A single generic file inside a File attachment (single or stacked). */
@@ -39,6 +39,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
39
39
  renderChannelActions,
40
40
  onParticipantNameClick,
41
41
  onBroadcastClick,
42
+ resolveBroadcastName,
42
43
  renderMessage,
43
44
  onMessageLinkClick,
44
45
  showChannelInfo,
@@ -265,6 +266,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
265
266
  renderChannelActions={renderChannelActions}
266
267
  onParticipantNameClick={onParticipantNameClick}
267
268
  onBroadcastClick={onBroadcastClick}
269
+ resolveBroadcastName={resolveBroadcastName}
268
270
  renderMessage={renderMessage}
269
271
  onMessageLinkClick={onMessageLinkClick}
270
272
  showChannelInfo={showChannelInfo}
package/src/index.ts CHANGED
@@ -61,6 +61,7 @@ export type {
61
61
  MessagingCapabilities,
62
62
  MessageLinkClickHandler,
63
63
  BroadcastClickHandler,
64
+ BroadcastNameResolver,
64
65
  Participant,
65
66
  LockedAttachmentSource,
66
67
  } from './types'
package/src/styles.css CHANGED
@@ -970,6 +970,11 @@
970
970
  word-break: break-word;
971
971
  }
972
972
 
973
+ /* Keep mentions readable by inheriting the message bubble's ink color. */
974
+ .str-chat__message-text-inner .str-chat__message-mention {
975
+ color: inherit;
976
+ }
977
+
973
978
  .str-chat__message
974
979
  .str-chat__message-inner
975
980
  .str-chat__message-bubble
@@ -1173,123 +1178,3 @@
1173
1178
  .message-locked-attachment-text .str-chat__message-text-inner {
1174
1179
  color: inherit;
1175
1180
  }
1176
-
1177
- /* Audio attachment waveform — `MessageAttachment.Audio` (MES-1360).
1178
-
1179
- Plain CSS rather than Tailwind utilities for two reasons: the per-bar
1180
- height is a floored custom property that has to be authored once
1181
- rather than repeated on 35 elements, and the played / unplayed fills
1182
- are the one design decision Figma left open (it draws the idle state
1183
- only), so keeping them in a single place is what makes them reviewable.
1184
- Follows the existing `.mes-media-viewer*` / `.mes-dialog` convention:
1185
- the package's shipped stylesheet is enough, with no Tailwind
1186
- dependency in the consumer.
1187
-
1188
- Geometry is Figma `2754:17710` → `sound-wave` (`…;2754:8065`): a
1189
- 138×30 track holding 35 bars at 2px with 2px gaps
1190
- (35 x 2 + 34 x 2 = 138). Bars flex from a zero basis, so the exact
1191
- 2px falls out of the 138px track and a narrower container degrades
1192
- proportionally instead of clipping. */
1193
- .mes-audio-wave {
1194
- position: relative;
1195
- display: flex;
1196
- flex: 1 1 0;
1197
- min-width: 0;
1198
- align-items: center;
1199
- height: 30px;
1200
- }
1201
-
1202
- .mes-audio-wave__track {
1203
- display: flex;
1204
- width: 100%;
1205
- height: 100%;
1206
- align-items: center;
1207
- gap: 2px;
1208
- }
1209
-
1210
- /* Figma's bars run 4px..30px on a 30px track, so the amplitude
1211
- percentage is floored at that 4px minimum: a silent sample — or the
1212
- all-zero fallback used when no waveform could be produced — still
1213
- draws the shortest bar the design already contains, keeping the
1214
- card's silhouette identical instead of collapsing it. */
1215
- .mes-audio-wave__bar {
1216
- flex: 1 1 0;
1217
- min-width: 0;
1218
- height: max(4px, var(--mes-audio-wave-bar, 0%));
1219
- border-radius: 9999px;
1220
- background: rgb(255 255 255 / 0.4);
1221
- }
1222
-
1223
- .mes-audio-wave__bar--played {
1224
- background: #ffffff;
1225
- }
1226
-
1227
- .mes-audio-wave--light .mes-audio-wave__bar {
1228
- background: rgb(4 4 3 / 0.5);
1229
- }
1230
-
1231
- .mes-audio-wave--light .mes-audio-wave__bar--played {
1232
- background: #040403;
1233
- }
1234
-
1235
- /* Seek is a real `<input type="range">` stretched over the bars, which
1236
- supplies click, drag, arrow keys, Home / End and a screen-reader
1237
- slider role natively. The bars are the visible track, so the input
1238
- itself must not paint — but it must stay focusable, which is why the
1239
- thumb and track are cleared individually rather than the whole input
1240
- being given `opacity: 0`. */
1241
- .mes-audio-wave__seek {
1242
- position: absolute;
1243
- inset: 0;
1244
- width: 100%;
1245
- height: 100%;
1246
- margin: 0;
1247
- padding: 0;
1248
- cursor: pointer;
1249
- background: transparent;
1250
- -webkit-appearance: none;
1251
- appearance: none;
1252
- }
1253
-
1254
- .mes-audio-wave__seek:disabled {
1255
- cursor: default;
1256
- }
1257
-
1258
- .mes-audio-wave__seek::-webkit-slider-runnable-track {
1259
- background: transparent;
1260
- }
1261
-
1262
- .mes-audio-wave__seek::-webkit-slider-thumb {
1263
- -webkit-appearance: none;
1264
- appearance: none;
1265
- width: 1px;
1266
- height: 100%;
1267
- opacity: 0;
1268
- }
1269
-
1270
- .mes-audio-wave__seek::-moz-range-track {
1271
- background: transparent;
1272
- }
1273
-
1274
- .mes-audio-wave__seek::-moz-range-thumb {
1275
- width: 1px;
1276
- height: 100%;
1277
- border: 0;
1278
- opacity: 0;
1279
- }
1280
-
1281
- .mes-audio-wave__seek:focus {
1282
- outline: none;
1283
- }
1284
-
1285
- /* The input is transparent, so the focus ring has to render on the
1286
- wrapper that actually has pixels. */
1287
- .mes-audio-wave:has(.mes-audio-wave__seek:focus-visible) {
1288
- outline: 2px solid rgb(255 255 255 / 0.6);
1289
- outline-offset: 4px;
1290
- border-radius: 4px;
1291
- }
1292
-
1293
- .mes-audio-wave--light:has(.mes-audio-wave__seek:focus-visible) {
1294
- outline-color: rgb(4 4 3 / 0.6);
1295
- }
package/src/types.ts CHANGED
@@ -41,6 +41,8 @@ export type BroadcastClickHandler = (broadcast: {
41
41
  name?: string
42
42
  }) => void
43
43
 
44
+ export type BroadcastNameResolver = (broadcastId: string) => string | undefined
45
+
44
46
  export type ChannelPreviewProps = ChannelPreviewUIComponentProps & {
45
47
  selectedChannel?: Channel | null
46
48
  onChannelSelect: (channel: Channel) => void
@@ -373,6 +375,14 @@ export interface ChannelViewProps {
373
375
  */
374
376
  onBroadcastClick?: BroadcastClickHandler
375
377
 
378
+ /**
379
+ * Resolves the current campaign name for a broadcast label. The host must
380
+ * provide a new resolver reference when a returned name changes so
381
+ * ChannelView re-renders and publishes the new value to memoized rows. The
382
+ * stamped message name remains the fallback until this returns a name.
383
+ */
384
+ resolveBroadcastName?: BroadcastNameResolver
385
+
376
386
  /**
377
387
  * Passed to Stream `Channel` as `SendButton`. Required for hosts that replace
378
388
  * the send control: `Channel` merges this into `ComponentContext` and an
@@ -442,6 +452,7 @@ export type ChannelViewPassthroughProps = Pick<
442
452
  | 'renderMessage'
443
453
  | 'onMessageLinkClick'
444
454
  | 'onBroadcastClick'
455
+ | 'resolveBroadcastName'
445
456
  | 'showChannelInfo'
446
457
  | 'composerInput'
447
458
  >