@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
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen, fireEvent } from '../../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import CardThumbnail, {
|
|
7
|
+
AUDIO_BG_CLASS,
|
|
8
|
+
isPlayableAudio,
|
|
9
|
+
isPlayableMedia,
|
|
10
|
+
} from './CardThumbnail'
|
|
11
|
+
|
|
12
|
+
const AUDIO_SRC = 'https://cdn.example.com/clip.mp3'
|
|
13
|
+
const VIDEO_SRC = 'https://cdn.example.com/clip.mp4'
|
|
14
|
+
const IMAGE_SRC = 'https://cdn.example.com/cover.jpg'
|
|
15
|
+
|
|
16
|
+
describe('isPlayableAudio', () => {
|
|
17
|
+
it('needs both an audio mime type and a source url', () => {
|
|
18
|
+
expect(isPlayableAudio('audio/mpeg', AUDIO_SRC)).toBe(true)
|
|
19
|
+
expect(isPlayableAudio('audio/mpeg', undefined)).toBe(false)
|
|
20
|
+
expect(isPlayableAudio('audio/mpeg', '')).toBe(false)
|
|
21
|
+
expect(isPlayableAudio(undefined, AUDIO_SRC)).toBe(false)
|
|
22
|
+
expect(isPlayableAudio('', AUDIO_SRC)).toBe(false)
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('rejects non-audio mime types', () => {
|
|
26
|
+
expect(isPlayableAudio('video/mp4', VIDEO_SRC)).toBe(false)
|
|
27
|
+
expect(isPlayableAudio('image/png', IMAGE_SRC)).toBe(false)
|
|
28
|
+
expect(isPlayableAudio('application/pdf', IMAGE_SRC)).toBe(false)
|
|
29
|
+
})
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
describe('isPlayableMedia', () => {
|
|
33
|
+
it('covers both playable shapes and nothing else', () => {
|
|
34
|
+
expect(isPlayableMedia('video/mp4', VIDEO_SRC)).toBe(true)
|
|
35
|
+
expect(isPlayableMedia('audio/mpeg', AUDIO_SRC)).toBe(true)
|
|
36
|
+
expect(isPlayableMedia('image/png', IMAGE_SRC)).toBe(false)
|
|
37
|
+
expect(isPlayableMedia('application/pdf', IMAGE_SRC)).toBe(false)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('needs both halves of the pair', () => {
|
|
41
|
+
expect(isPlayableMedia('video/mp4', undefined)).toBe(false)
|
|
42
|
+
expect(isPlayableMedia('video/mp4', '')).toBe(false)
|
|
43
|
+
expect(isPlayableMedia(undefined, VIDEO_SRC)).toBe(false)
|
|
44
|
+
expect(isPlayableMedia('', VIDEO_SRC)).toBe(false)
|
|
45
|
+
})
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
describe('AUDIO_BG_CLASS', () => {
|
|
49
|
+
it('is a verbatim literal so Tailwind can find it when scanning dist', () => {
|
|
50
|
+
expect(AUDIO_BG_CLASS).toBe('bg-[#F2F3F4]')
|
|
51
|
+
})
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
describe('CardThumbnail', () => {
|
|
55
|
+
it('collapses the hero to a padded native audio player', () => {
|
|
56
|
+
const { container } = renderWithProviders(
|
|
57
|
+
<CardThumbnail
|
|
58
|
+
variant="dark"
|
|
59
|
+
mimeType="audio/mpeg"
|
|
60
|
+
sourceUrl={AUDIO_SRC}
|
|
61
|
+
thumbnailUrl={IMAGE_SRC}
|
|
62
|
+
/>
|
|
63
|
+
)
|
|
64
|
+
const audio = container.querySelector('audio')
|
|
65
|
+
expect(audio).not.toBeNull()
|
|
66
|
+
expect(audio?.getAttribute('src')).toBe(AUDIO_SRC)
|
|
67
|
+
expect(audio?.hasAttribute('controls')).toBe(true)
|
|
68
|
+
expect(audio?.getAttribute('preload')).toBe('metadata')
|
|
69
|
+
// The hero region (and its artwork) is gone entirely on this branch.
|
|
70
|
+
expect(container.querySelectorAll('img')).toHaveLength(0)
|
|
71
|
+
expect(container.querySelector('div.flex-1')).toBeNull()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('keeps the skeleton instead of an audio player while loading', () => {
|
|
75
|
+
const { container } = renderWithProviders(
|
|
76
|
+
<CardThumbnail
|
|
77
|
+
variant="dark"
|
|
78
|
+
mimeType="audio/mpeg"
|
|
79
|
+
sourceUrl={AUDIO_SRC}
|
|
80
|
+
isLoading
|
|
81
|
+
/>
|
|
82
|
+
)
|
|
83
|
+
expect(container.querySelector('audio')).toBeNull()
|
|
84
|
+
expect(container.querySelector('div.animate-pulse')).not.toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('renders a video player with the poster over a black hero', () => {
|
|
88
|
+
const { container } = renderWithProviders(
|
|
89
|
+
<CardThumbnail
|
|
90
|
+
variant="light"
|
|
91
|
+
mimeType="video/mp4"
|
|
92
|
+
sourceUrl={VIDEO_SRC}
|
|
93
|
+
thumbnailUrl={IMAGE_SRC}
|
|
94
|
+
title="Clip"
|
|
95
|
+
onImageError={() => {}}
|
|
96
|
+
/>
|
|
97
|
+
)
|
|
98
|
+
const video = container.querySelector('video')
|
|
99
|
+
expect(video?.getAttribute('src')).toBe(VIDEO_SRC)
|
|
100
|
+
expect(video?.getAttribute('poster')).toBe(IMAGE_SRC)
|
|
101
|
+
expect(video?.hasAttribute('controls')).toBe(true)
|
|
102
|
+
expect(video?.getAttribute('preload')).toBe('metadata')
|
|
103
|
+
expect(container.querySelector('div.flex-1')?.className).toContain(
|
|
104
|
+
'bg-black'
|
|
105
|
+
)
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
it('probes the video poster with a hidden image that reports failures', () => {
|
|
109
|
+
const onImageError = vi.fn()
|
|
110
|
+
const { container } = renderWithProviders(
|
|
111
|
+
<CardThumbnail
|
|
112
|
+
variant="light"
|
|
113
|
+
mimeType="video/mp4"
|
|
114
|
+
sourceUrl={VIDEO_SRC}
|
|
115
|
+
thumbnailUrl={IMAGE_SRC}
|
|
116
|
+
onImageError={onImageError}
|
|
117
|
+
/>
|
|
118
|
+
)
|
|
119
|
+
const probe = container.querySelector('img')
|
|
120
|
+
expect(probe).not.toBeNull()
|
|
121
|
+
expect(probe?.className).toContain('hidden')
|
|
122
|
+
expect(probe?.getAttribute('aria-hidden')).toBe('true')
|
|
123
|
+
fireEvent.error(probe as HTMLImageElement)
|
|
124
|
+
expect(onImageError).toHaveBeenCalledTimes(1)
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
it('skips the poster probe when there is nothing to report to', () => {
|
|
128
|
+
const { container: noHandler } = renderWithProviders(
|
|
129
|
+
<CardThumbnail
|
|
130
|
+
variant="light"
|
|
131
|
+
mimeType="video/mp4"
|
|
132
|
+
sourceUrl={VIDEO_SRC}
|
|
133
|
+
thumbnailUrl={IMAGE_SRC}
|
|
134
|
+
/>
|
|
135
|
+
)
|
|
136
|
+
expect(noHandler.querySelectorAll('img')).toHaveLength(0)
|
|
137
|
+
|
|
138
|
+
const { container: noPoster } = renderWithProviders(
|
|
139
|
+
<CardThumbnail
|
|
140
|
+
variant="light"
|
|
141
|
+
mimeType="video/mp4"
|
|
142
|
+
sourceUrl={VIDEO_SRC}
|
|
143
|
+
onImageError={() => {}}
|
|
144
|
+
/>
|
|
145
|
+
)
|
|
146
|
+
expect(noPoster.querySelectorAll('img')).toHaveLength(0)
|
|
147
|
+
expect(noPoster.querySelector('video')?.getAttribute('poster')).toBeNull()
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('renders the thumbnail image with the title as its alt text', () => {
|
|
151
|
+
const { container } = renderWithProviders(
|
|
152
|
+
<CardThumbnail
|
|
153
|
+
variant="light"
|
|
154
|
+
thumbnailUrl={IMAGE_SRC}
|
|
155
|
+
title="Cover art"
|
|
156
|
+
/>
|
|
157
|
+
)
|
|
158
|
+
const image = container.querySelector('img')
|
|
159
|
+
expect(image?.getAttribute('src')).toBe(IMAGE_SRC)
|
|
160
|
+
expect(image?.getAttribute('alt')).toBe('Cover art')
|
|
161
|
+
expect(image?.getAttribute('loading')).toBe('lazy')
|
|
162
|
+
expect(image?.getAttribute('decoding')).toBe('async')
|
|
163
|
+
expect(image?.getAttribute('draggable')).toBe('false')
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('falls back to empty alt text when the card has no title', () => {
|
|
167
|
+
const { container } = renderWithProviders(
|
|
168
|
+
<CardThumbnail variant="light" thumbnailUrl={IMAGE_SRC} />
|
|
169
|
+
)
|
|
170
|
+
expect(container.querySelector('img')?.getAttribute('alt')).toBe('')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('reports a failing hero image to its owner', () => {
|
|
174
|
+
const onImageError = vi.fn()
|
|
175
|
+
const { container } = renderWithProviders(
|
|
176
|
+
<CardThumbnail
|
|
177
|
+
variant="light"
|
|
178
|
+
thumbnailUrl={IMAGE_SRC}
|
|
179
|
+
onImageError={onImageError}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
fireEvent.error(container.querySelector('img') as HTMLImageElement)
|
|
183
|
+
expect(onImageError).toHaveBeenCalledTimes(1)
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
it('shows a variant-aware type-icon placeholder without a thumbnail', () => {
|
|
187
|
+
const { container: dark } = renderWithProviders(
|
|
188
|
+
<CardThumbnail variant="dark" mimeType="application/pdf" />
|
|
189
|
+
)
|
|
190
|
+
expect(dark.querySelectorAll('img')).toHaveLength(0)
|
|
191
|
+
const darkPlaceholder = dark.querySelector('div.items-center')
|
|
192
|
+
expect(darkPlaceholder?.className).toContain('bg-white/10')
|
|
193
|
+
expect(darkPlaceholder?.querySelector('svg')?.getAttribute('class')).toBe(
|
|
194
|
+
'size-16 text-white/25'
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
const { container: light } = renderWithProviders(
|
|
198
|
+
<CardThumbnail variant="light" />
|
|
199
|
+
)
|
|
200
|
+
const lightPlaceholder = light.querySelector('div.items-center')
|
|
201
|
+
expect(lightPlaceholder?.className).toContain('bg-black/5')
|
|
202
|
+
expect(lightPlaceholder?.querySelector('svg')?.getAttribute('class')).toBe(
|
|
203
|
+
'size-16 text-black/25'
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
it('pulses a variant-aware block while loading, ignoring the thumbnail', () => {
|
|
208
|
+
const { container: dark } = renderWithProviders(
|
|
209
|
+
<CardThumbnail variant="dark" thumbnailUrl={IMAGE_SRC} isLoading />
|
|
210
|
+
)
|
|
211
|
+
expect(dark.querySelectorAll('img')).toHaveLength(0)
|
|
212
|
+
const bar = dark.querySelector('div.animate-pulse')
|
|
213
|
+
expect(bar?.className).toContain('bg-white/10')
|
|
214
|
+
expect(bar?.getAttribute('aria-hidden')).toBe('true')
|
|
215
|
+
|
|
216
|
+
const { container: light } = renderWithProviders(
|
|
217
|
+
<CardThumbnail variant="light" isLoading />
|
|
218
|
+
)
|
|
219
|
+
expect(light.querySelector('div.animate-pulse')?.className).toContain(
|
|
220
|
+
'bg-black/5'
|
|
221
|
+
)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('pins the corner slots and omits their wrappers when unused', () => {
|
|
225
|
+
renderWithProviders(
|
|
226
|
+
<CardThumbnail
|
|
227
|
+
variant="dark"
|
|
228
|
+
topLeft={<span>left</span>}
|
|
229
|
+
topRight={<span>right</span>}
|
|
230
|
+
/>
|
|
231
|
+
)
|
|
232
|
+
expect(screen.getByText('left').parentElement?.className).toContain(
|
|
233
|
+
'absolute left-3 top-3'
|
|
234
|
+
)
|
|
235
|
+
expect(screen.getByText('right').parentElement?.className).toContain(
|
|
236
|
+
'absolute right-3 top-3'
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
const { container } = renderWithProviders(<CardThumbnail variant="dark" />)
|
|
240
|
+
expect(container.querySelectorAll('div.absolute')).toHaveLength(0)
|
|
241
|
+
})
|
|
242
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { HEX_PATTERN, toCssHex } from './hex'
|
|
4
|
+
|
|
5
|
+
describe('HEX_PATTERN', () => {
|
|
6
|
+
it.each(['#7008E7', '7008e7', '#abc', 'ABC'])('accepts %s', (value) => {
|
|
7
|
+
expect(HEX_PATTERN.test(value)).toBe(true)
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it.each([
|
|
11
|
+
'#7008E', // 5 digits — neither shorthand nor full
|
|
12
|
+
'#ab', // too short for the shorthand
|
|
13
|
+
'#abcz', // trailing non-hex character
|
|
14
|
+
'x#abc', // hex embedded in a longer value
|
|
15
|
+
'#abc ', // untrimmed
|
|
16
|
+
'rebeccapurple', // CSS would resolve it; the extractor never emits it
|
|
17
|
+
'rgb(1,2,3)',
|
|
18
|
+
'',
|
|
19
|
+
])('rejects %s', (value) => {
|
|
20
|
+
expect(HEX_PATTERN.test(value)).toBe(false)
|
|
21
|
+
})
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('toCssHex', () => {
|
|
25
|
+
it('adds the missing hash to a bare hex', () => {
|
|
26
|
+
expect(toCssHex('7008E7')).toBe('#7008E7')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('leaves an already-hashed hex alone', () => {
|
|
30
|
+
expect(toCssHex('#7008E7')).toBe('#7008E7')
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('trims before hashing so the value is usable as a CSS colour', () => {
|
|
34
|
+
expect(toCssHex(' 7008E7 ')).toBe('#7008E7')
|
|
35
|
+
expect(toCssHex(' #7008E7 ')).toBe('#7008E7')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { normalizeExternalHref } from './normalizeExternalHref'
|
|
4
|
+
|
|
5
|
+
describe('normalizeExternalHref', () => {
|
|
6
|
+
it('prefixes a bare hostname so it opens externally', () => {
|
|
7
|
+
expect(normalizeExternalHref('tr.ee/briemix')).toBe('https://tr.ee/briemix')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('trims surrounding whitespace before prefixing', () => {
|
|
11
|
+
expect(normalizeExternalHref(' tr.ee/briemix ')).toBe(
|
|
12
|
+
'https://tr.ee/briemix'
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns undefined for missing, non-string and blank values', () => {
|
|
17
|
+
expect(normalizeExternalHref()).toBeUndefined()
|
|
18
|
+
expect(normalizeExternalHref(undefined)).toBeUndefined()
|
|
19
|
+
expect(
|
|
20
|
+
normalizeExternalHref(42 as unknown as string | undefined)
|
|
21
|
+
).toBeUndefined()
|
|
22
|
+
expect(normalizeExternalHref('')).toBeUndefined()
|
|
23
|
+
expect(normalizeExternalHref(' ')).toBeUndefined()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it.each([
|
|
27
|
+
'http://example.com/a',
|
|
28
|
+
'https://example.com/a',
|
|
29
|
+
'mailto:hello@example.com',
|
|
30
|
+
'tel:+61400000000',
|
|
31
|
+
'sms:+61400000000',
|
|
32
|
+
])('passes the safe scheme %s through untouched', (value) => {
|
|
33
|
+
expect(normalizeExternalHref(value)).toBe(value)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('accepts a safe scheme regardless of case', () => {
|
|
37
|
+
expect(normalizeExternalHref('HTTPS://example.com')).toBe(
|
|
38
|
+
'HTTPS://example.com'
|
|
39
|
+
)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it.each([
|
|
43
|
+
'javascript:alert(1)',
|
|
44
|
+
'data:text/html;base64,PHNjcmlwdD4=',
|
|
45
|
+
'ftp://example.com/a',
|
|
46
|
+
'file:///etc/passwd',
|
|
47
|
+
])('rejects the unsafe scheme %s', (value) => {
|
|
48
|
+
expect(normalizeExternalHref(value)).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('only reads a scheme at the start of the value', () => {
|
|
52
|
+
// A nested `https:` inside a query string is not this value's scheme, so
|
|
53
|
+
// the bare host still gets prefixed.
|
|
54
|
+
expect(normalizeExternalHref('tr.ee/a?next=https://example.com')).toBe(
|
|
55
|
+
'https://tr.ee/a?next=https://example.com'
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('keeps protocol-relative and root-relative paths as-is', () => {
|
|
60
|
+
expect(normalizeExternalHref('//cdn.example.com/a.png')).toBe(
|
|
61
|
+
'//cdn.example.com/a.png'
|
|
62
|
+
)
|
|
63
|
+
expect(normalizeExternalHref('/local/path')).toBe('/local/path')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('prefixes hosts that merely end in a slash', () => {
|
|
67
|
+
// The leading-slash checks are prefix tests: a trailing slash is still a
|
|
68
|
+
// bare host and must be prefixed.
|
|
69
|
+
expect(normalizeExternalHref('example.com/')).toBe('https://example.com/')
|
|
70
|
+
expect(normalizeExternalHref('example.com//')).toBe('https://example.com//')
|
|
71
|
+
})
|
|
72
|
+
})
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { act, renderHook } from '@testing-library/react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { useChinPalette } from './useChinPalette'
|
|
5
|
+
import type { ChinPaletteInput } from './useChinPalette'
|
|
6
|
+
|
|
7
|
+
const THUMBNAIL = 'https://cdn.example.com/cover.jpg'
|
|
8
|
+
|
|
9
|
+
const render = (input: ChinPaletteInput) =>
|
|
10
|
+
renderHook((props: ChinPaletteInput) => useChinPalette(props), {
|
|
11
|
+
initialProps: input,
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
describe('useChinPalette', () => {
|
|
15
|
+
it('publishes a validated accent for a ready featured hero', () => {
|
|
16
|
+
const { result } = render({
|
|
17
|
+
variant: 'dark',
|
|
18
|
+
accentColor: '#7008E7',
|
|
19
|
+
thumbnailUrl: THUMBNAIL,
|
|
20
|
+
})
|
|
21
|
+
expect(result.current.accentHex).toBe('#7008E7')
|
|
22
|
+
expect(result.current.thumbnailUrl).toBe(THUMBNAIL)
|
|
23
|
+
expect(result.current.isLoading).toBe(false)
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('hashes a bare hex and tolerates surrounding whitespace', () => {
|
|
27
|
+
const { result } = render({
|
|
28
|
+
variant: 'dark',
|
|
29
|
+
accentColor: ' 7008E7 ',
|
|
30
|
+
thumbnailUrl: THUMBNAIL,
|
|
31
|
+
})
|
|
32
|
+
expect(result.current.accentHex).toBe('#7008E7')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it.each([
|
|
36
|
+
'rebeccapurple',
|
|
37
|
+
'rgb(112, 8, 231)',
|
|
38
|
+
'#12g',
|
|
39
|
+
'#1234',
|
|
40
|
+
'',
|
|
41
|
+
undefined,
|
|
42
|
+
])('drops the unusable accent %s', (accentColor) => {
|
|
43
|
+
const { result } = render({
|
|
44
|
+
variant: 'dark',
|
|
45
|
+
accentColor,
|
|
46
|
+
thumbnailUrl: THUMBNAIL,
|
|
47
|
+
})
|
|
48
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('drops the accent for a classic card, which has no hero', () => {
|
|
52
|
+
const { result } = render({
|
|
53
|
+
variant: 'dark',
|
|
54
|
+
accentColor: '#7008E7',
|
|
55
|
+
layout: 'classic',
|
|
56
|
+
thumbnailUrl: THUMBNAIL,
|
|
57
|
+
})
|
|
58
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it.each(['loading', 'failed'] as const)(
|
|
62
|
+
'drops the accent while status is %s',
|
|
63
|
+
(status) => {
|
|
64
|
+
const { result } = render({
|
|
65
|
+
variant: 'dark',
|
|
66
|
+
accentColor: '#7008E7',
|
|
67
|
+
status,
|
|
68
|
+
thumbnailUrl: THUMBNAIL,
|
|
69
|
+
})
|
|
70
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
71
|
+
expect(result.current.isLoading).toBe(status === 'loading')
|
|
72
|
+
}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
it.each([undefined, ''])(
|
|
76
|
+
'drops the accent when the hero url is %s',
|
|
77
|
+
(thumbnailUrl) => {
|
|
78
|
+
const { result } = render({
|
|
79
|
+
variant: 'dark',
|
|
80
|
+
accentColor: '#7008E7',
|
|
81
|
+
thumbnailUrl,
|
|
82
|
+
})
|
|
83
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
it('drops the accent for playable audio, whose hero is the player', () => {
|
|
88
|
+
const { result } = render({
|
|
89
|
+
variant: 'dark',
|
|
90
|
+
accentColor: '#7008E7',
|
|
91
|
+
thumbnailUrl: THUMBNAIL,
|
|
92
|
+
mimeType: 'audio/mpeg',
|
|
93
|
+
sourceUrl: 'https://cdn.example.com/clip.mp3',
|
|
94
|
+
})
|
|
95
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('keeps the accent for playable video, whose poster is the hero', () => {
|
|
99
|
+
const { result } = render({
|
|
100
|
+
variant: 'dark',
|
|
101
|
+
accentColor: '#7008E7',
|
|
102
|
+
thumbnailUrl: THUMBNAIL,
|
|
103
|
+
mimeType: 'video/mp4',
|
|
104
|
+
sourceUrl: 'https://cdn.example.com/clip.mp4',
|
|
105
|
+
})
|
|
106
|
+
expect(result.current.accentHex).toBe('#7008E7')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('withdraws the hero and its accent once the image fails', () => {
|
|
110
|
+
const { result } = render({
|
|
111
|
+
variant: 'dark',
|
|
112
|
+
accentColor: '#7008E7',
|
|
113
|
+
thumbnailUrl: THUMBNAIL,
|
|
114
|
+
})
|
|
115
|
+
act(() => {
|
|
116
|
+
result.current.onImageError()
|
|
117
|
+
})
|
|
118
|
+
expect(result.current.thumbnailUrl).toBeUndefined()
|
|
119
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('gives a new hero url a fresh chance to load', () => {
|
|
123
|
+
const { result, rerender } = render({
|
|
124
|
+
variant: 'dark',
|
|
125
|
+
accentColor: '#7008E7',
|
|
126
|
+
thumbnailUrl: THUMBNAIL,
|
|
127
|
+
})
|
|
128
|
+
act(() => {
|
|
129
|
+
result.current.onImageError()
|
|
130
|
+
})
|
|
131
|
+
expect(result.current.accentHex).toBeUndefined()
|
|
132
|
+
|
|
133
|
+
rerender({
|
|
134
|
+
variant: 'dark',
|
|
135
|
+
accentColor: '#7008E7',
|
|
136
|
+
thumbnailUrl: 'https://cdn.example.com/other.jpg',
|
|
137
|
+
})
|
|
138
|
+
expect(result.current.thumbnailUrl).toBe(
|
|
139
|
+
'https://cdn.example.com/other.jpg'
|
|
140
|
+
)
|
|
141
|
+
expect(result.current.accentHex).toBe('#7008E7')
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it.each([
|
|
145
|
+
['dark', '#FFFFFF'],
|
|
146
|
+
['light', '#040403'],
|
|
147
|
+
] as const)('inks the %s surface with %s', (variant, ink) => {
|
|
148
|
+
const { result } = render({ variant })
|
|
149
|
+
expect(result.current.chinTextColor).toBe(ink)
|
|
150
|
+
expect(result.current.surface).toBe(variant)
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('treats the audio strip as a light surface on either side', () => {
|
|
154
|
+
const { result } = render({
|
|
155
|
+
variant: 'dark',
|
|
156
|
+
mimeType: 'audio/mpeg',
|
|
157
|
+
sourceUrl: 'https://cdn.example.com/clip.mp3',
|
|
158
|
+
})
|
|
159
|
+
expect(result.current.chinTextColor).toBe('#040403')
|
|
160
|
+
expect(result.current.surface).toBe('light')
|
|
161
|
+
})
|
|
162
|
+
})
|