@linktr.ee/messaging-react 4.2.0-rc-1787083791 → 4.2.0-rc-1787181113
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-B4lMv-0o.js → Card-BOudQBd6.js} +2 -2
- package/dist/{Card-B4lMv-0o.js.map → Card-BOudQBd6.js.map} +1 -1
- package/dist/{Card-DWL5I62S.cjs → Card-k2959zNX.cjs} +2 -2
- package/dist/{Card-DWL5I62S.cjs.map → Card-k2959zNX.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-C93LTGLc.js → index-6RdyZh6F.js} +1809 -1912
- package/dist/index-6RdyZh6F.js.map +1 -0
- package/dist/index-BcU18EjV.cjs +3 -0
- package/dist/index-BcU18EjV.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +78 -59
- package/dist/index.js +30 -26
- package/dist/testing.cjs +1 -1
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.js +2 -2
- package/dist/testing.js.map +1 -1
- package/package.json +2 -2
- package/src/components/AttachmentCard/MediaPlayer.test.tsx +346 -0
- package/src/components/AttachmentCard/Thumbnail.test.tsx +215 -0
- package/src/components/AttachmentCard/index.test.tsx +160 -0
- package/src/components/Avatar/Avatar.test.tsx +126 -0
- package/src/components/Avatar/getAvatarEmoji.test.ts +39 -0
- package/src/components/ChannelList/ChannelListContext.test.tsx +87 -0
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +410 -3
- package/src/components/ChannelList/index.test.tsx +132 -1
- package/src/components/CustomMessage/CustomMessage.stories.tsx +6 -314
- package/src/components/CustomMessage/CustomMessage.test.tsx +56 -2
- package/src/components/CustomMessage/Locked.stories.tsx +108 -0
- package/src/components/CustomMessage/MessageTag.stories.tsx +17 -53
- package/src/components/CustomMessage/MessageTag.test.tsx +7 -40
- package/src/components/CustomMessage/MessageTag.tsx +7 -39
- package/src/components/CustomMessage/PaidMessage.stories.tsx +42 -0
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +8 -7
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +2 -1
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +0 -33
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +0 -6
- package/src/components/CustomMessage/Tip.stories.tsx +48 -0
- package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +51 -0
- package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +65 -0
- package/src/components/CustomMessage/TipMessage/index.tsx +27 -0
- package/src/components/CustomMessage/index.tsx +22 -19
- package/src/components/FaqList/FaqList.test.tsx +204 -0
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +327 -0
- package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +76 -0
- package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +248 -0
- package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +123 -0
- package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +193 -0
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +242 -0
- package/src/components/LinkAttachment/components/_shared/hex.test.ts +37 -0
- package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +72 -0
- package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +162 -0
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +58 -165
- package/src/components/MessageAttachment/Audio/index.tsx +77 -71
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +74 -176
- package/src/components/MessageAttachment/index.tsx +9 -25
- package/src/components/MessageAttachment/types.ts +9 -19
- package/src/components/MessagingShell/MessagingShell.test.tsx +20 -0
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/index.ts +4 -0
- package/src/logging/index.test.tsx +136 -0
- package/src/logging/index.tsx +48 -0
- package/src/providers/MessagingProvider.test.tsx +113 -1
- package/src/providers/MessagingProvider.tsx +25 -15
- package/src/stories/customMessageStoryTemplate.tsx +199 -0
- package/src/styles.css +10 -121
- package/src/testing/createMockMessagingClient.ts +2 -2
- package/src/types.ts +9 -0
- package/dist/index-B-9LkWep.cjs +0 -3
- package/dist/index-B-9LkWep.cjs.map +0 -1
- package/dist/index-C93LTGLc.js.map +0 -1
- 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,50 @@ describe('MessageAttachment.File', () => {
|
|
|
736
716
|
})
|
|
737
717
|
|
|
738
718
|
describe('MessageAttachment.Audio', () => {
|
|
739
|
-
|
|
740
|
-
|
|
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.
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
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
|
-
})
|
|
728
|
+
expect(bubble.querySelector('audio')).not.toBeNull()
|
|
729
|
+
// No filename title — the native `<audio>` element doesn't expose
|
|
730
|
+
// a title slot, so we render just the player.
|
|
731
|
+
expect(screen.queryByText('song.mp3')).toBeNull()
|
|
732
|
+
// Native player handles its own download in the kebab menu, so
|
|
733
|
+
// no extra Download button should be rendered.
|
|
734
|
+
expect(screen.queryByRole('button', { name: /download/i })).toBeNull()
|
|
794
735
|
})
|
|
795
736
|
|
|
796
|
-
it
|
|
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…
|
|
737
|
+
it('shows an inline dismiss button next to the player on Composer state', () => {
|
|
828
738
|
const onDismiss = vi.fn()
|
|
829
|
-
|
|
739
|
+
renderWithProviders(
|
|
830
740
|
<MessageAttachment.Audio.Composer
|
|
831
741
|
src="https://cdn.example.com/song.mp3"
|
|
832
|
-
filename="song.mp3"
|
|
833
742
|
onDismiss={onDismiss}
|
|
834
743
|
/>
|
|
835
744
|
)
|
|
836
|
-
|
|
837
|
-
expect(
|
|
838
|
-
|
|
839
|
-
).not.toBeInTheDocument()
|
|
840
|
-
fireEvent.click(screen.getByRole('button', { name: 'Remove attachment' }))
|
|
745
|
+
const dismiss = screen.getByLabelText('Dismiss attachment')
|
|
746
|
+
expect(dismiss).toBeInTheDocument()
|
|
747
|
+
fireEvent.click(dismiss)
|
|
841
748
|
expect(onDismiss).toHaveBeenCalledTimes(1)
|
|
842
749
|
})
|
|
843
750
|
|
|
844
|
-
it('
|
|
845
|
-
|
|
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(
|
|
751
|
+
it('renders one native player per item for stacked audio', () => {
|
|
752
|
+
renderWithProviders(
|
|
865
753
|
<MessageAttachment.Audio.Sent
|
|
866
754
|
items={[
|
|
867
|
-
{ src: 'https://cdn.example.com/a.mp3',
|
|
868
|
-
{ src: 'https://cdn.example.com/b.mp3',
|
|
869
|
-
{ src: 'https://cdn.example.com/c.mp3',
|
|
755
|
+
{ src: 'https://cdn.example.com/a.mp3', mimeType: 'audio/mpeg' },
|
|
756
|
+
{ src: 'https://cdn.example.com/b.mp3', mimeType: 'audio/mpeg' },
|
|
757
|
+
{ src: 'https://cdn.example.com/c.mp3', mimeType: 'audio/mpeg' },
|
|
870
758
|
]}
|
|
871
759
|
/>
|
|
872
760
|
)
|
|
873
|
-
|
|
874
761
|
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()
|
|
762
|
+
expect(bubble.querySelectorAll('audio')).toHaveLength(3)
|
|
919
763
|
})
|
|
920
764
|
})
|
|
921
765
|
|
|
@@ -1107,6 +951,60 @@ describe('MessageAttachment lazy-loading defaults', () => {
|
|
|
1107
951
|
expect(video?.getAttribute('preload')).toBe('metadata')
|
|
1108
952
|
})
|
|
1109
953
|
})
|
|
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
|
+
})
|
|
1110
1008
|
})
|
|
1111
1009
|
|
|
1112
1010
|
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 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
|
|
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** —
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
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 —
|
|
122
|
+
/** MIME type hint — typed onto the inline `<source>` element. */
|
|
123
123
|
mimeType?: string
|
|
124
124
|
/**
|
|
125
|
-
* Filename — the download default name
|
|
126
|
-
*
|
|
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
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
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
|
-
|
|
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). */
|
|
@@ -125,6 +125,26 @@ describe('MessagingShell', () => {
|
|
|
125
125
|
})
|
|
126
126
|
})
|
|
127
127
|
|
|
128
|
+
it('forwards attachmentPreviewList to the selected channel view', async () => {
|
|
129
|
+
queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])
|
|
130
|
+
const AttachmentPreviewList = () => <div>staged attachment</div>
|
|
131
|
+
|
|
132
|
+
renderWithProviders(
|
|
133
|
+
<MessagingShell
|
|
134
|
+
initialParticipantFilter="other-1"
|
|
135
|
+
attachmentPreviewList={AttachmentPreviewList}
|
|
136
|
+
/>
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
await waitFor(() => {
|
|
140
|
+
expect(screen.getByTestId('channel-view')).toBeInTheDocument()
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
expect(capturedChannelViewProps.attachmentPreviewList).toBe(
|
|
144
|
+
AttachmentPreviewList
|
|
145
|
+
)
|
|
146
|
+
})
|
|
147
|
+
|
|
128
148
|
it('calls onExitConversation and renders "Conversation ended" when the user leaves the conversation', async () => {
|
|
129
149
|
queryChannelsMock.mockResolvedValue([makeChannel('existing-1')])
|
|
130
150
|
const onExit = vi.fn()
|
|
@@ -43,6 +43,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
43
43
|
onMessageLinkClick,
|
|
44
44
|
showChannelInfo,
|
|
45
45
|
composerInput,
|
|
46
|
+
attachmentPreviewList,
|
|
46
47
|
}) => {
|
|
47
48
|
const {
|
|
48
49
|
client,
|
|
@@ -269,6 +270,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
269
270
|
onMessageLinkClick={onMessageLinkClick}
|
|
270
271
|
showChannelInfo={showChannelInfo}
|
|
271
272
|
composerInput={composerInput}
|
|
273
|
+
attachmentPreviewList={attachmentPreviewList}
|
|
272
274
|
/>
|
|
273
275
|
</div>
|
|
274
276
|
</div>
|
package/src/index.ts
CHANGED
|
@@ -7,6 +7,7 @@ export { ChannelList } from './components/ChannelList'
|
|
|
7
7
|
export { ChannelView } from './components/ChannelView'
|
|
8
8
|
export { default as ActionButton } from './components/ActionButton'
|
|
9
9
|
export { default as LockedAttachment } from './components/CustomMessage/LockedAttachment'
|
|
10
|
+
export { TipMessage, isTipMessage } from './components/CustomMessage/TipMessage'
|
|
10
11
|
export { default as LinkAttachment } from './components/LinkAttachment'
|
|
11
12
|
export { default as RichCard } from './components/RichCard'
|
|
12
13
|
export { default as MessageAttachment } from './components/MessageAttachment'
|
|
@@ -28,11 +29,13 @@ export type {
|
|
|
28
29
|
|
|
29
30
|
// Providers
|
|
30
31
|
export { MessagingProvider } from './providers/MessagingProvider'
|
|
32
|
+
export { MessagingLoggerProvider } from './logging'
|
|
31
33
|
export { CustomMessageProvider } from './components/CustomMessage/context'
|
|
32
34
|
|
|
33
35
|
// Hooks
|
|
34
36
|
export { useComposerLocked } from './components/CustomMessageInput'
|
|
35
37
|
export { useMessaging } from './hooks/useMessaging'
|
|
38
|
+
export { useMessagingLogger } from './logging'
|
|
36
39
|
export { useCustomMessage } from './components/CustomMessage/context'
|
|
37
40
|
|
|
38
41
|
// Utils
|
|
@@ -135,3 +138,4 @@ export type { CustomMessageRegistry } from './components/CustomMessage/context'
|
|
|
135
138
|
export type { AttachmentSourceType } from './components/AttachmentCard/utils/mimeType'
|
|
136
139
|
export type { Faq, FaqListProps } from './components/FaqList'
|
|
137
140
|
export type { FaqListItemProps } from './components/FaqList/FaqListItem'
|
|
141
|
+
export type { Logger } from '@linktr.ee/messaging-core'
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import type { Logger } from '@linktr.ee/messaging-core'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
MessagingLoggerProvider,
|
|
8
|
+
consoleLogger,
|
|
9
|
+
resolveLogger,
|
|
10
|
+
useMessagingLogger,
|
|
11
|
+
} from './index'
|
|
12
|
+
|
|
13
|
+
const ERROR_MESSAGE = '[MessagingShell] Failed to load channels:'
|
|
14
|
+
|
|
15
|
+
/** Logs one error through the seam the way a call site would. */
|
|
16
|
+
const ErrorEmitter: React.FC<{ error: Error }> = ({ error }) => {
|
|
17
|
+
const logger = useMessagingLogger()
|
|
18
|
+
logger.error(ERROR_MESSAGE, error)
|
|
19
|
+
return null
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Emits one line at every level so partial-sink fallback is observable. */
|
|
23
|
+
const AllLevelsEmitter: React.FC = () => {
|
|
24
|
+
const logger = useMessagingLogger()
|
|
25
|
+
logger.debug('debug line', 1)
|
|
26
|
+
logger.info('info line', 2)
|
|
27
|
+
logger.warn('warn line', 3)
|
|
28
|
+
logger.error('error line', 4)
|
|
29
|
+
return null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => {
|
|
33
|
+
vi.restoreAllMocks()
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
describe('logger seam', () => {
|
|
37
|
+
it('routes error to an injected sink with the exact message and raw error object', () => {
|
|
38
|
+
const error = new Error('boom')
|
|
39
|
+
const sink = { error: vi.fn() }
|
|
40
|
+
|
|
41
|
+
render(
|
|
42
|
+
<MessagingLoggerProvider logger={sink}>
|
|
43
|
+
<ErrorEmitter error={error} />
|
|
44
|
+
</MessagingLoggerProvider>
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(sink.error).toHaveBeenCalledTimes(1)
|
|
48
|
+
expect(sink.error).toHaveBeenCalledWith(ERROR_MESSAGE, error)
|
|
49
|
+
// The raw Error instance, not a serialized copy.
|
|
50
|
+
expect(sink.error.mock.calls[0][1]).toBe(error)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('passes identical arguments to console.error when no sink is injected', () => {
|
|
54
|
+
const error = new Error('boom')
|
|
55
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
56
|
+
const sink = { error: vi.fn() }
|
|
57
|
+
|
|
58
|
+
render(
|
|
59
|
+
<MessagingLoggerProvider logger={sink}>
|
|
60
|
+
<ErrorEmitter error={error} />
|
|
61
|
+
</MessagingLoggerProvider>
|
|
62
|
+
)
|
|
63
|
+
const injectedArgs = sink.error.mock.calls[0]
|
|
64
|
+
|
|
65
|
+
// No provider at all, and a provider with no logger prop: both must produce
|
|
66
|
+
// byte-identical console output to what the injected sink received.
|
|
67
|
+
render(<ErrorEmitter error={error} />)
|
|
68
|
+
render(
|
|
69
|
+
<MessagingLoggerProvider>
|
|
70
|
+
<ErrorEmitter error={error} />
|
|
71
|
+
</MessagingLoggerProvider>
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
expect(consoleError).toHaveBeenCalledTimes(2)
|
|
75
|
+
expect(consoleError.mock.calls[0]).toEqual(injectedArgs)
|
|
76
|
+
expect(consoleError.mock.calls[1]).toEqual(injectedArgs)
|
|
77
|
+
expect(consoleError.mock.calls[0][1]).toBe(error)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('falls back to console for the levels a partial sink omits', () => {
|
|
81
|
+
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
82
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
83
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
84
|
+
const sink: Logger = { warn: vi.fn(), error: vi.fn() }
|
|
85
|
+
|
|
86
|
+
render(
|
|
87
|
+
<MessagingLoggerProvider logger={sink}>
|
|
88
|
+
<AllLevelsEmitter />
|
|
89
|
+
</MessagingLoggerProvider>
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
expect(sink.warn).toHaveBeenCalledWith('warn line', 3)
|
|
93
|
+
expect(sink.error).toHaveBeenCalledWith('error line', 4)
|
|
94
|
+
expect(consoleWarn).not.toHaveBeenCalled()
|
|
95
|
+
expect(consoleError).not.toHaveBeenCalled()
|
|
96
|
+
// debug and info are unset on the sink, so both land on console.log.
|
|
97
|
+
expect(consoleLog.mock.calls).toEqual([
|
|
98
|
+
['debug line', 1],
|
|
99
|
+
['info line', 2],
|
|
100
|
+
])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('defaults every level to console when the hook is used with no provider', () => {
|
|
104
|
+
const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => {})
|
|
105
|
+
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
106
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
107
|
+
|
|
108
|
+
render(<AllLevelsEmitter />)
|
|
109
|
+
|
|
110
|
+
expect(consoleLog.mock.calls).toEqual([
|
|
111
|
+
['debug line', 1],
|
|
112
|
+
['info line', 2],
|
|
113
|
+
])
|
|
114
|
+
expect(consoleWarn).toHaveBeenCalledWith('warn line', 3)
|
|
115
|
+
expect(consoleError).toHaveBeenCalledWith('error line', 4)
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('resolveLogger keeps provided levels and fills the rest from console', () => {
|
|
119
|
+
const warn = vi.fn()
|
|
120
|
+
const resolved = resolveLogger({ warn })
|
|
121
|
+
|
|
122
|
+
expect(resolved.warn).toBe(warn)
|
|
123
|
+
expect(resolved.debug).toBe(consoleLogger.debug)
|
|
124
|
+
expect(resolved.info).toBe(consoleLogger.info)
|
|
125
|
+
expect(resolved.error).toBe(consoleLogger.error)
|
|
126
|
+
expect(resolveLogger()).toEqual(consoleLogger)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('consoleLogger dispatches through console at call time, not bind time', () => {
|
|
130
|
+
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
|
|
131
|
+
|
|
132
|
+
consoleLogger.error('late spy', 'arg')
|
|
133
|
+
|
|
134
|
+
expect(consoleError).toHaveBeenCalledWith('late spy', 'arg')
|
|
135
|
+
})
|
|
136
|
+
})
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { Logger } from '@linktr.ee/messaging-core'
|
|
2
|
+
import React, { createContext, useContext, useMemo } from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Default sink: the console calls the package makes today. Wrappers, not
|
|
6
|
+
* `console.error.bind(console)`, so a later-installed spy is still observed.
|
|
7
|
+
*/
|
|
8
|
+
export const consoleLogger: Required<Logger> = {
|
|
9
|
+
debug: (message, ...args) => console.log(message, ...args),
|
|
10
|
+
info: (message, ...args) => console.log(message, ...args),
|
|
11
|
+
warn: (message, ...args) => console.warn(message, ...args),
|
|
12
|
+
error: (message, ...args) => console.error(message, ...args),
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Merge a partial sink over the console defaults, so a consumer can route only
|
|
17
|
+
* the levels it cares about (e.g. `error`) and leave the rest on console.
|
|
18
|
+
*/
|
|
19
|
+
export const resolveLogger = (logger?: Logger): Required<Logger> => ({
|
|
20
|
+
debug: logger?.debug ?? consoleLogger.debug,
|
|
21
|
+
info: logger?.info ?? consoleLogger.info,
|
|
22
|
+
warn: logger?.warn ?? consoleLogger.warn,
|
|
23
|
+
error: logger?.error ?? consoleLogger.error,
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
const LoggerContext = createContext<Required<Logger>>(consoleLogger)
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Provides the resolved logger sink to the messaging subtree. Rendered by
|
|
30
|
+
* `MessagingProvider`; usable standalone in tests or in a subtree that needs a
|
|
31
|
+
* different sink.
|
|
32
|
+
*/
|
|
33
|
+
export const MessagingLoggerProvider: React.FC<{
|
|
34
|
+
logger?: Logger
|
|
35
|
+
children: React.ReactNode
|
|
36
|
+
}> = ({ logger, children }) => {
|
|
37
|
+
const value = useMemo(() => resolveLogger(logger), [logger])
|
|
38
|
+
return (
|
|
39
|
+
<LoggerContext.Provider value={value}>{children}</LoggerContext.Provider>
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The logger sink for the current subtree. Falls back to console when no
|
|
45
|
+
* provider (or no `logger` prop) is present, so call sites never branch.
|
|
46
|
+
*/
|
|
47
|
+
export const useMessagingLogger = (): Required<Logger> =>
|
|
48
|
+
useContext(LoggerContext)
|