@linktr.ee/messaging-react 4.2.0-rc-1787325521 → 4.2.0-rc-1787599724
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-BYJyA83X.js → Card-D7UPq5-g.js} +2 -2
- package/dist/{Card-BYJyA83X.js.map → Card-D7UPq5-g.js.map} +1 -1
- package/dist/{Card-BplJtZao.cjs → Card-DcBZqMBt.cjs} +2 -2
- package/dist/{Card-BplJtZao.cjs.map → Card-DcBZqMBt.cjs.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-BUC9vqhJ.js → index-CSkr_CO5.js} +2089 -1952
- package/dist/index-CSkr_CO5.js.map +1 -0
- package/dist/index-CwkY7QwT.cjs +3 -0
- package/dist/index-CwkY7QwT.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +59 -78
- package/dist/index.js +26 -30
- 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 +13 -11
- package/src/components/AttachmentCard/utils/icons.ts +7 -6
- package/src/components/Avatar/Avatar.test.tsx +0 -126
- package/src/components/Avatar/getAvatarEmoji.test.ts +0 -39
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +3 -410
- package/src/components/ChannelList/index.test.tsx +1 -132
- package/src/components/ChannelView.stories.tsx +0 -4
- package/src/components/ChannelView.test.tsx +3 -9
- package/src/components/CustomMessage/CustomMessage.stories.tsx +314 -6
- package/src/components/CustomMessage/CustomMessage.test.tsx +2 -56
- package/src/components/CustomMessage/MessageTag.stories.tsx +53 -17
- package/src/components/CustomMessage/MessageTag.test.tsx +40 -7
- package/src/components/CustomMessage/MessageTag.tsx +39 -7
- package/src/components/CustomMessage/SentMessageDeliveryStatus.test.tsx +7 -8
- package/src/components/CustomMessage/SentMessageDeliveryStatus.tsx +1 -2
- package/src/components/CustomMessage/StreamAttachmentMessage.test.tsx +33 -0
- package/src/components/CustomMessage/StreamAttachmentMessage.tsx +12 -5
- package/src/components/CustomMessage/index.tsx +19 -22
- package/src/components/CustomMessageInput/index.tsx +1 -1
- package/src/components/CustomSystemMessage/index.tsx +2 -3
- package/src/components/LinkAttachment/LinkAttachment.test.tsx +0 -327
- package/src/components/MessageAttachment/Audio/AudioAttachment.stories.tsx +165 -58
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.test.tsx +153 -0
- package/src/components/MessageAttachment/Audio/WaveformAudioRow.tsx +450 -0
- package/src/components/MessageAttachment/Audio/audioRowRequester.test.ts +24 -0
- package/src/components/MessageAttachment/Audio/audioRowRequester.ts +16 -0
- package/src/components/MessageAttachment/Audio/index.tsx +83 -77
- package/src/components/MessageAttachment/MessageAttachment.test.tsx +176 -74
- package/src/components/MessageAttachment/_shared/fileMeta.test.ts +0 -44
- package/src/components/MessageAttachment/_shared/useIsClient.ts +23 -0
- package/src/components/MessageAttachment/index.tsx +25 -9
- package/src/components/MessageAttachment/types.ts +19 -9
- package/src/components/MessagingShell/MessagingShell.test.tsx +0 -20
- package/src/components/MessagingShell/index.tsx +0 -2
- package/src/components/RichCard/RichCard.test.tsx +1 -3
- package/src/index.ts +0 -4
- package/src/providers/MessagingProvider.test.tsx +2 -130
- package/src/providers/MessagingProvider.tsx +15 -26
- package/src/styles.css +210 -57
- package/src/testing/createMockMessagingClient.ts +2 -2
- package/src/types.ts +0 -9
- package/dist/index-BUC9vqhJ.js.map +0 -1
- package/dist/index-BkqyKPJP.cjs +0 -3
- package/dist/index-BkqyKPJP.cjs.map +0 -1
- package/src/components/AttachmentCard/MediaPlayer.test.tsx +0 -346
- package/src/components/AttachmentCard/Thumbnail.test.tsx +0 -215
- package/src/components/AttachmentCard/index.test.tsx +0 -160
- package/src/components/ChannelList/ChannelListContext.test.tsx +0 -87
- package/src/components/CustomMessage/Locked.stories.tsx +0 -108
- package/src/components/CustomMessage/PaidMessage.stories.tsx +0 -42
- package/src/components/CustomMessage/Tip.stories.tsx +0 -48
- package/src/components/CustomMessage/TipMessage/TipMessage.stories.tsx +0 -51
- package/src/components/CustomMessage/TipMessage/TipMessage.test.tsx +0 -65
- package/src/components/CustomMessage/TipMessage/index.tsx +0 -27
- package/src/components/FaqList/FaqList.test.tsx +0 -204
- package/src/components/LinkAttachment/components/_shared/BubbleTail.test.tsx +0 -76
- package/src/components/LinkAttachment/components/_shared/CardBody.test.tsx +0 -248
- package/src/components/LinkAttachment/components/_shared/CardCta.test.tsx +0 -123
- package/src/components/LinkAttachment/components/_shared/CardShell.test.tsx +0 -193
- package/src/components/LinkAttachment/components/_shared/CardThumbnail.test.tsx +0 -242
- package/src/components/LinkAttachment/components/_shared/hex.test.ts +0 -37
- package/src/components/LinkAttachment/components/_shared/normalizeExternalHref.test.ts +0 -72
- package/src/components/LinkAttachment/components/_shared/useChinPalette.test.ts +0 -162
- package/src/components/MessageAttachment/MessageAttachment.behavior.test.tsx +0 -182
- package/src/components/MessageAttachment/_shared/Bubble.test.tsx +0 -111
- package/src/components/MessageAttachment/_shared/CarouselNav.test.tsx +0 -36
- package/src/components/MessageAttachment/_shared/CompactDocumentRow.test.tsx +0 -114
- package/src/components/MessageAttachment/_shared/DismissButton.test.tsx +0 -42
- package/src/components/MessageAttachment/_shared/DownloadAction.test.tsx +0 -168
- package/src/components/MessageAttachment/_shared/ImageViewer.test.tsx +0 -52
- package/src/components/MessageAttachment/_shared/MediaStackGrid.test.tsx +0 -69
- package/src/components/MessageAttachment/_shared/PdfViewer.test.tsx +0 -45
- package/src/components/MessageAttachment/_shared/VideoViewer.test.tsx +0 -55
- package/src/components/MessageAttachment/_shared/ViewerShell.test.tsx +0 -246
- package/src/components/MessageAttachment/_shared/triggerDownload.test.ts +0 -113
- package/src/components/MessageAttachment/_shared/useCarousel.test.tsx +0 -68
- package/src/components/MessageAttachment/_shared/viewerChromeClasses.test.ts +0 -16
- package/src/logging/index.test.tsx +0 -136
- package/src/logging/index.tsx +0 -48
- package/src/stories/customMessageStoryTemplate.tsx +0 -202
|
@@ -1,93 +1,75 @@
|
|
|
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 { audioRowRequester } from './audioRowRequester'
|
|
14
|
+
import WaveformAudioRow, {
|
|
15
|
+
type AudioRowTrailingAction,
|
|
16
|
+
} from './WaveformAudioRow'
|
|
17
|
+
|
|
18
|
+
// Figma `2754:17710` → `attachment-audio` (`…;2754:8062`): a 299×72
|
|
19
|
+
// card on `#1E2330` with a 20/12/20/20 inset, a uniform 20px radius and
|
|
20
|
+
// no stroke. `@linktr.ee/component-library@11.8.6`'s radius scale is
|
|
21
|
+
// 8 / 16 / 24 / 64 / full, so there is no 20px token to reach for.
|
|
22
|
+
//
|
|
23
|
+
// The radius is uniform in every state, so the card opts out of the
|
|
24
|
+
// same-author corner flattening the text bubbles apply — a 32px control
|
|
25
|
+
// tucked into a 4px corner would collide with it.
|
|
26
|
+
const CARD_WIDTH_CLASS = 'w-[299px]'
|
|
27
|
+
const CARD_PADDING_CLASS = 'py-5 pl-5 pr-3'
|
|
28
|
+
const CARD_RADIUS_CLASS = 'rounded-[20px]'
|
|
29
|
+
|
|
14
30
|
export interface AudioAttachmentSharedProps extends MessageAttachmentBaseProps {
|
|
15
31
|
/** Audio source URL (`mp3`, `aac`, `wav`, …). */
|
|
16
32
|
src?: string
|
|
17
|
-
/** MIME type hint —
|
|
33
|
+
/** MIME type hint — gates playability via the player's `canPlayType` probe. */
|
|
18
34
|
mimeType?: string
|
|
19
35
|
/**
|
|
20
|
-
* Filename —
|
|
21
|
-
*
|
|
22
|
-
* expose a built-in title slot, so we don't render the filename
|
|
23
|
-
* inside the bubble itself.
|
|
36
|
+
* Filename — the download default name for the trailing action, and
|
|
37
|
+
* part of each control's accessible name.
|
|
24
38
|
*/
|
|
25
39
|
filename?: string
|
|
26
40
|
/**
|
|
27
41
|
* Stacked audio. Takes precedence over `src` when set. Each item
|
|
28
|
-
* renders its own
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
* attachment at a time.
|
|
42
|
+
* renders its own waveform row, vertically stacked inside the same
|
|
43
|
+
* bubble with an 8px gap. Sent + Received only — the composer surface
|
|
44
|
+
* accepts a single attachment at a time.
|
|
32
45
|
*/
|
|
33
46
|
items?: AudioItem[]
|
|
34
|
-
/**
|
|
35
|
-
|
|
36
|
-
|
|
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
|
|
47
|
+
/** See `AudioItem.durationSeconds`. Applies to the single-`src` shape. */
|
|
48
|
+
durationSeconds?: number
|
|
49
|
+
/** See `AudioItem.waveformData`. Applies to the single-`src` shape. */
|
|
50
|
+
waveformData?: number[]
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const resolveItems = ({
|
|
51
54
|
src,
|
|
52
55
|
mimeType,
|
|
53
56
|
filename,
|
|
57
|
+
durationSeconds,
|
|
58
|
+
waveformData,
|
|
54
59
|
items,
|
|
55
60
|
}: {
|
|
56
61
|
src?: string
|
|
57
62
|
mimeType?: string
|
|
58
63
|
filename?: string
|
|
64
|
+
durationSeconds?: number
|
|
65
|
+
waveformData?: number[]
|
|
59
66
|
items?: AudioItem[]
|
|
60
67
|
}): AudioItem[] => {
|
|
61
68
|
if (items && items.length > 0) return items
|
|
62
|
-
if (src) return [{ src, mimeType, filename }]
|
|
69
|
+
if (src) return [{ src, mimeType, filename, durationSeconds, waveformData }]
|
|
63
70
|
return []
|
|
64
71
|
}
|
|
65
72
|
|
|
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
73
|
interface InternalAudioRowProps extends AudioAttachmentSharedProps {
|
|
92
74
|
state: MessageAttachmentState
|
|
93
75
|
onDismiss?: () => void
|
|
@@ -99,52 +81,74 @@ const AudioAttachmentRow: React.FC<InternalAudioRowProps> = ({
|
|
|
99
81
|
mimeType,
|
|
100
82
|
filename,
|
|
101
83
|
items,
|
|
84
|
+
durationSeconds,
|
|
85
|
+
waveformData,
|
|
102
86
|
text,
|
|
103
87
|
groupPosition,
|
|
104
|
-
preload,
|
|
105
88
|
onDismiss,
|
|
106
89
|
}) => {
|
|
90
|
+
// Namespaces the pooled players per message, so scrolling a
|
|
91
|
+
// virtualized thread away and back restores the playback position
|
|
92
|
+
// instead of starting a fresh player. Returns `{}` (silently) outside
|
|
93
|
+
// a message, which is the composer case.
|
|
94
|
+
const { message, threadList } = useMessageContext()
|
|
95
|
+
// Fallback namespace for a card rendered outside a message — the
|
|
96
|
+
// composer, a story, a consumer mounting one directly. Stable for the
|
|
97
|
+
// life of the component, which is all those surfaces need; a message
|
|
98
|
+
// id is preferred because it also survives a virtualized thread
|
|
99
|
+
// scrolling the row away and back.
|
|
100
|
+
const instanceId = React.useId()
|
|
101
|
+
|
|
107
102
|
const variant = bubbleVariantForState(state)
|
|
108
|
-
const
|
|
109
|
-
|
|
103
|
+
const resolvedItems = resolveItems({
|
|
104
|
+
src,
|
|
105
|
+
mimeType,
|
|
106
|
+
filename,
|
|
107
|
+
durationSeconds,
|
|
108
|
+
waveformData,
|
|
109
|
+
items,
|
|
110
|
+
})
|
|
110
111
|
|
|
111
112
|
if (resolvedItems.length === 0) {
|
|
112
113
|
return null
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
116
|
+
const requesterBase = message?.id
|
|
117
|
+
? `${threadList ? (message.parent_id ?? message.id) : ''}${message.id}`
|
|
118
|
+
: instanceId
|
|
119
|
+
|
|
120
|
+
// The composer's single action is removing the draft; every other
|
|
121
|
+
// surface carries forward the download the native player's own kebab
|
|
122
|
+
// used to provide. See `AudioRowTrailingAction`.
|
|
123
|
+
// A composer draft mounted without `onDismiss` gets no trailing
|
|
124
|
+
// control: falling through to `download` would hand a draft the
|
|
125
|
+
// sent-only action, which the native player it replaces never did.
|
|
126
|
+
const trailingAction: AudioRowTrailingAction | undefined =
|
|
127
|
+
state === 'composer'
|
|
128
|
+
? onDismiss
|
|
129
|
+
? { kind: 'dismiss', onDismiss }
|
|
130
|
+
: undefined
|
|
131
|
+
: { kind: 'download' }
|
|
121
132
|
|
|
122
133
|
return (
|
|
123
134
|
<Bubble
|
|
124
135
|
variant={variant}
|
|
125
136
|
text={text}
|
|
126
137
|
groupPosition={groupPosition}
|
|
138
|
+
bordered={false}
|
|
139
|
+
widthClassName={CARD_WIDTH_CLASS}
|
|
140
|
+
paddingClassName={CARD_PADDING_CLASS}
|
|
141
|
+
radiusClassName={CARD_RADIUS_CLASS}
|
|
127
142
|
data-testid="audio-attachment"
|
|
128
143
|
>
|
|
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
144
|
<div className="flex flex-col gap-2">
|
|
136
145
|
{resolvedItems.map((item, index) => (
|
|
137
|
-
<
|
|
146
|
+
<WaveformAudioRow
|
|
138
147
|
key={`${item.src}-${index}`}
|
|
139
148
|
item={item}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// dismiss control sits on the only player.
|
|
144
|
-
showDismiss && index === 0 ? (
|
|
145
|
-
<DismissButton onClick={onDismiss!} variant="inline" />
|
|
146
|
-
) : undefined
|
|
147
|
-
}
|
|
149
|
+
variant={variant}
|
|
150
|
+
requester={audioRowRequester(requesterBase, index)}
|
|
151
|
+
trailingAction={trailingAction}
|
|
148
152
|
/>
|
|
149
153
|
))}
|
|
150
154
|
</div>
|
|
@@ -164,8 +168,10 @@ export interface AudioComposerProps extends ComposerExtras {
|
|
|
164
168
|
src?: string
|
|
165
169
|
mimeType?: string
|
|
166
170
|
filename?: string
|
|
167
|
-
/** See `
|
|
168
|
-
|
|
171
|
+
/** See `AudioItem.durationSeconds`. */
|
|
172
|
+
durationSeconds?: number
|
|
173
|
+
/** See `AudioItem.waveformData`. */
|
|
174
|
+
waveformData?: number[]
|
|
169
175
|
}
|
|
170
176
|
export type AudioSentProps = AudioAttachmentSharedProps
|
|
171
177
|
export type AudioReceivedProps = AudioAttachmentSharedProps
|
|
@@ -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', () => {
|
|
@@ -25,14 +25,6 @@ describe('formatFileSize', () => {
|
|
|
25
25
|
expect(formatFileSize(Infinity)).toBe('')
|
|
26
26
|
expect(formatFileSize(-1)).toBe('')
|
|
27
27
|
})
|
|
28
|
-
|
|
29
|
-
it('handles unit boundaries without crossing into the next unit early', () => {
|
|
30
|
-
expect(formatFileSize(1023)).toBe('1023 B')
|
|
31
|
-
expect(formatFileSize(1024 * 1024 - 1)).toBe('1024.0 KB')
|
|
32
|
-
expect(formatFileSize(1024 * 1024)).toBe('1.00 MB')
|
|
33
|
-
expect(formatFileSize(1024 ** 3 - 1)).toBe('1024.00 MB')
|
|
34
|
-
expect(formatFileSize(1024 ** 3)).toBe('1.00 GB')
|
|
35
|
-
})
|
|
36
28
|
})
|
|
37
29
|
|
|
38
30
|
describe('getFileExtensionLabel', () => {
|
|
@@ -51,18 +43,6 @@ describe('getFileExtensionLabel', () => {
|
|
|
51
43
|
expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
|
|
52
44
|
expect(getFileExtensionLabel()).toBeUndefined()
|
|
53
45
|
})
|
|
54
|
-
|
|
55
|
-
it('handles dot boundaries, extension length, and MIME fallbacks', () => {
|
|
56
|
-
expect(getFileExtensionLabel('text/plain', '.gitignore')).toBe('TXT')
|
|
57
|
-
expect(getFileExtensionLabel('text/plain', 'notes.')).toBe('TXT')
|
|
58
|
-
expect(getFileExtensionLabel('image/webp', 'photo.webp')).toBe('WEBP')
|
|
59
|
-
expect(getFileExtensionLabel('image/webp', 'photo.abcdef')).toBe('WEBP')
|
|
60
|
-
expect(getFileExtensionLabel('application/json', 'data.12345')).toBe(
|
|
61
|
-
'12345'
|
|
62
|
-
)
|
|
63
|
-
expect(getFileExtensionLabel('image/*')).toBeUndefined()
|
|
64
|
-
expect(getFileExtensionLabel('application/octet-stream')).toBeUndefined()
|
|
65
|
-
})
|
|
66
46
|
})
|
|
67
47
|
|
|
68
48
|
describe('buildCompactMetaLabel', () => {
|
|
@@ -79,23 +59,6 @@ describe('buildCompactMetaLabel', () => {
|
|
|
79
59
|
it('returns undefined when nothing is renderable', () => {
|
|
80
60
|
expect(buildCompactMetaLabel()).toBeUndefined()
|
|
81
61
|
})
|
|
82
|
-
|
|
83
|
-
it('drops non-positive and non-number sizes while retaining size-only labels', () => {
|
|
84
|
-
expect(buildCompactMetaLabel('application/pdf', 'report.pdf', 0)).toBe(
|
|
85
|
-
'PDF'
|
|
86
|
-
)
|
|
87
|
-
expect(buildCompactMetaLabel('application/pdf', 'report.pdf', -1)).toBe(
|
|
88
|
-
'PDF'
|
|
89
|
-
)
|
|
90
|
-
expect(buildCompactMetaLabel(undefined, undefined, 1024)).toBe('1.0 KB')
|
|
91
|
-
expect(
|
|
92
|
-
buildCompactMetaLabel(
|
|
93
|
-
'application/octet-stream',
|
|
94
|
-
undefined,
|
|
95
|
-
'1024' as never
|
|
96
|
-
)
|
|
97
|
-
).toBeUndefined()
|
|
98
|
-
})
|
|
99
62
|
})
|
|
100
63
|
|
|
101
64
|
describe('filenameFromUrl', () => {
|
|
@@ -114,11 +77,4 @@ describe('filenameFromUrl', () => {
|
|
|
114
77
|
it('falls back to a default when the URL cannot be parsed', () => {
|
|
115
78
|
expect(filenameFromUrl('not a url at all')).toBe('download')
|
|
116
79
|
})
|
|
117
|
-
|
|
118
|
-
it('handles trailing slashes, deep paths, and query strings', () => {
|
|
119
|
-
expect(filenameFromUrl('https://cdn.example.com/folder/')).toBe('download')
|
|
120
|
-
expect(
|
|
121
|
-
filenameFromUrl('https://cdn.example.com/a/b/c/report.pdf?download=1')
|
|
122
|
-
).toBe('report.pdf')
|
|
123
|
-
})
|
|
124
80
|
})
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
/** No store to subscribe to — the value flips once, at hydration. */
|
|
4
|
+
const subscribe = () => () => {}
|
|
5
|
+
const getSnapshot = () => true
|
|
6
|
+
const getServerSnapshot = () => false
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* `false` while server-rendering and for the hydration render, `true`
|
|
10
|
+
* from the first client render onwards.
|
|
11
|
+
*
|
|
12
|
+
* `useSyncExternalStore` is what makes that safe: React uses
|
|
13
|
+
* `getServerSnapshot` for both the server pass and hydration, so the
|
|
14
|
+
* two trees match and only then does the component re-render as
|
|
15
|
+
* interactive. The `useState` + `useEffect` spelling of this produces
|
|
16
|
+
* the same result but trips `react-hooks/set-state-in-effect`.
|
|
17
|
+
*
|
|
18
|
+
* Used to keep browser-only work out of a server render — see
|
|
19
|
+
* `WaveformAudioRow`, where constructing Stream's pooled `AudioPlayer`
|
|
20
|
+
* reaches for `new Audio()` (MES-1360).
|
|
21
|
+
*/
|
|
22
|
+
export const useIsClient = (): boolean =>
|
|
23
|
+
useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)
|