@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.
- package/dist/{Card-DDv7af2K.js → Card-B4lMv-0o.js} +2 -2
- package/dist/{Card-DDv7af2K.js.map → Card-B4lMv-0o.js.map} +1 -1
- package/dist/{Card-BD16XAaT.cjs → Card-DWL5I62S.cjs} +2 -2
- package/dist/{Card-BD16XAaT.cjs.map → Card-DWL5I62S.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-B-9LkWep.cjs +3 -0
- package/dist/index-B-9LkWep.cjs.map +1 -0
- package/dist/{index-qKUd4Fz2.js → index-C93LTGLc.js} +1495 -1356
- package/dist/index-C93LTGLc.js.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +58 -43
- package/dist/index.js +1 -1
- package/package.json +2 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +6 -0
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +335 -0
- package/src/components/MessageAttachment/Audio/index.tsx +71 -77
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
- package/src/components/MessageAttachment/index.tsx +25 -9
- package/src/components/MessageAttachment/types.ts +19 -9
- package/src/styles.css +120 -0
- package/dist/index-9elRVMIF.cjs +0 -3
- package/dist/index-9elRVMIF.cjs.map +0 -1
- package/dist/index-qKUd4Fz2.js.map +0 -1
|
@@ -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
|
-
|
|
720
|
-
|
|
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.
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
746
|
-
expect(
|
|
747
|
-
|
|
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('
|
|
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
865
|
<MessageAttachment.Audio.Sent
|
|
754
866
|
items={[
|
|
755
|
-
{ src: 'https://cdn.example.com/a.mp3',
|
|
756
|
-
{ src: 'https://cdn.example.com/b.mp3',
|
|
757
|
-
{ src: 'https://cdn.example.com/c.mp3',
|
|
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(
|
|
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', () => {
|
|
@@ -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
|
|
59
|
+
* `PdfViewer` at that index, Audio gets its own waveform 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,12 +74,29 @@ 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
|
|
77
|
+
* buttons on Pdf / File and the Audio card's trailing action 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
|
+
*
|
|
83
100
|
* # Lazy-loading defaults
|
|
84
101
|
*
|
|
85
102
|
* Media attachments bake in network-friendly defaults for long chat
|
|
@@ -100,13 +117,12 @@ import VideoAttachment, {
|
|
|
100
117
|
* `VideoItem.preload` per item). Once the viewer is open the
|
|
101
118
|
* active item bumps to `preload="metadata"` so duration / the
|
|
102
119
|
* first frame appear immediately.
|
|
103
|
-
* - **Audio** —
|
|
104
|
-
* `
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* (or `AudioItem.preload` per track).
|
|
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.
|
|
110
126
|
* - **Pdf** / **File** — no inline media element to throttle; the
|
|
111
127
|
* bubble renders compact rows that download / open lazily on
|
|
112
128
|
* activation. No `loading` / `preload` props on these surfaces.
|
|
@@ -119,21 +119,31 @@ 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 —
|
|
122
|
+
/** MIME type hint — gates playability via the player's `canPlayType` probe. */
|
|
123
123
|
mimeType?: string
|
|
124
124
|
/**
|
|
125
|
-
* Filename —
|
|
126
|
-
*
|
|
125
|
+
* Filename — the download default name for the card's trailing
|
|
126
|
+
* action, and part of the card's accessible name.
|
|
127
127
|
*/
|
|
128
128
|
filename?: string
|
|
129
129
|
/**
|
|
130
|
-
*
|
|
131
|
-
* the
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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.
|
|
135
135
|
*/
|
|
136
|
-
|
|
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[]
|
|
137
147
|
}
|
|
138
148
|
|
|
139
149
|
/** A single generic file inside a File attachment (single or stacked). */
|
package/src/styles.css
CHANGED
|
@@ -941,3 +941,123 @@
|
|
|
941
941
|
.message-locked-attachment-text .str-chat__message-text-inner {
|
|
942
942
|
color: inherit;
|
|
943
943
|
}
|
|
944
|
+
|
|
945
|
+
/* Audio attachment waveform — `MessageAttachment.Audio` (MES-1360).
|
|
946
|
+
|
|
947
|
+
Plain CSS rather than Tailwind utilities for two reasons: the per-bar
|
|
948
|
+
height is a floored custom property that has to be authored once
|
|
949
|
+
rather than repeated on 35 elements, and the played / unplayed fills
|
|
950
|
+
are the one design decision Figma left open (it draws the idle state
|
|
951
|
+
only), so keeping them in a single place is what makes them reviewable.
|
|
952
|
+
Follows the existing `.mes-media-viewer*` / `.mes-dialog` convention:
|
|
953
|
+
the package's shipped stylesheet is enough, with no Tailwind
|
|
954
|
+
dependency in the consumer.
|
|
955
|
+
|
|
956
|
+
Geometry is Figma `2754:17710` → `sound-wave` (`…;2754:8065`): a
|
|
957
|
+
138×30 track holding 35 bars at 2px with 2px gaps
|
|
958
|
+
(35 x 2 + 34 x 2 = 138). Bars flex from a zero basis, so the exact
|
|
959
|
+
2px falls out of the 138px track and a narrower container degrades
|
|
960
|
+
proportionally instead of clipping. */
|
|
961
|
+
.mes-audio-wave {
|
|
962
|
+
position: relative;
|
|
963
|
+
display: flex;
|
|
964
|
+
flex: 1 1 0;
|
|
965
|
+
min-width: 0;
|
|
966
|
+
align-items: center;
|
|
967
|
+
height: 30px;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
.mes-audio-wave__track {
|
|
971
|
+
display: flex;
|
|
972
|
+
width: 100%;
|
|
973
|
+
height: 100%;
|
|
974
|
+
align-items: center;
|
|
975
|
+
gap: 2px;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
/* Figma's bars run 4px..30px on a 30px track, so the amplitude
|
|
979
|
+
percentage is floored at that 4px minimum: a silent sample — or the
|
|
980
|
+
all-zero fallback used when no waveform could be produced — still
|
|
981
|
+
draws the shortest bar the design already contains, keeping the
|
|
982
|
+
card's silhouette identical instead of collapsing it. */
|
|
983
|
+
.mes-audio-wave__bar {
|
|
984
|
+
flex: 1 1 0;
|
|
985
|
+
min-width: 0;
|
|
986
|
+
height: max(4px, var(--mes-audio-wave-bar, 0%));
|
|
987
|
+
border-radius: 9999px;
|
|
988
|
+
background: rgb(255 255 255 / 0.4);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
.mes-audio-wave__bar--played {
|
|
992
|
+
background: #ffffff;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.mes-audio-wave--light .mes-audio-wave__bar {
|
|
996
|
+
background: rgb(4 4 3 / 0.5);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.mes-audio-wave--light .mes-audio-wave__bar--played {
|
|
1000
|
+
background: #040403;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/* Seek is a real `<input type="range">` stretched over the bars, which
|
|
1004
|
+
supplies click, drag, arrow keys, Home / End and a screen-reader
|
|
1005
|
+
slider role natively. The bars are the visible track, so the input
|
|
1006
|
+
itself must not paint — but it must stay focusable, which is why the
|
|
1007
|
+
thumb and track are cleared individually rather than the whole input
|
|
1008
|
+
being given `opacity: 0`. */
|
|
1009
|
+
.mes-audio-wave__seek {
|
|
1010
|
+
position: absolute;
|
|
1011
|
+
inset: 0;
|
|
1012
|
+
width: 100%;
|
|
1013
|
+
height: 100%;
|
|
1014
|
+
margin: 0;
|
|
1015
|
+
padding: 0;
|
|
1016
|
+
cursor: pointer;
|
|
1017
|
+
background: transparent;
|
|
1018
|
+
-webkit-appearance: none;
|
|
1019
|
+
appearance: none;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.mes-audio-wave__seek:disabled {
|
|
1023
|
+
cursor: default;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
.mes-audio-wave__seek::-webkit-slider-runnable-track {
|
|
1027
|
+
background: transparent;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
.mes-audio-wave__seek::-webkit-slider-thumb {
|
|
1031
|
+
-webkit-appearance: none;
|
|
1032
|
+
appearance: none;
|
|
1033
|
+
width: 1px;
|
|
1034
|
+
height: 100%;
|
|
1035
|
+
opacity: 0;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
.mes-audio-wave__seek::-moz-range-track {
|
|
1039
|
+
background: transparent;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
.mes-audio-wave__seek::-moz-range-thumb {
|
|
1043
|
+
width: 1px;
|
|
1044
|
+
height: 100%;
|
|
1045
|
+
border: 0;
|
|
1046
|
+
opacity: 0;
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
.mes-audio-wave__seek:focus {
|
|
1050
|
+
outline: none;
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/* The input is transparent, so the focus ring has to render on the
|
|
1054
|
+
wrapper that actually has pixels. */
|
|
1055
|
+
.mes-audio-wave:has(.mes-audio-wave__seek:focus-visible) {
|
|
1056
|
+
outline: 2px solid rgb(255 255 255 / 0.6);
|
|
1057
|
+
outline-offset: 4px;
|
|
1058
|
+
border-radius: 4px;
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
.mes-audio-wave--light:has(.mes-audio-wave__seek:focus-visible) {
|
|
1062
|
+
outline-color: rgb(4 4 3 / 0.6);
|
|
1063
|
+
}
|