@linktr.ee/messaging-react 4.5.0-rc-1788297811 → 4.5.0-rc-1788325575
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "4.5.0-rc-
|
|
3
|
+
"version": "4.5.0-rc-1788325575",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -50,8 +50,8 @@
|
|
|
50
50
|
"chromatic:local": "yarn storybook:build && chromatic"
|
|
51
51
|
},
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@linktr.ee/messaging-core": "2.6.0-rc-
|
|
54
|
-
"@linktr.ee/messaging-taxonomy": "0.
|
|
53
|
+
"@linktr.ee/messaging-core": "2.6.0-rc-1788325575",
|
|
54
|
+
"@linktr.ee/messaging-taxonomy": "0.12.0-rc-1788325575",
|
|
55
55
|
"@phosphor-icons/react": "^2.1.10"
|
|
56
56
|
},
|
|
57
57
|
"devDependencies": {
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { Channel } from 'stream-chat'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { renderWithProviders, screen } from '../test/utils'
|
|
6
|
+
|
|
7
|
+
import ChannelHeader from './ChannelHeader'
|
|
8
|
+
|
|
9
|
+
let activeChannel: unknown
|
|
10
|
+
let activeClient: unknown
|
|
11
|
+
|
|
12
|
+
vi.mock('stream-chat-react', () => ({
|
|
13
|
+
useChannelStateContext: () => ({ channel: activeChannel }),
|
|
14
|
+
useChatContext: () => ({ client: activeClient }),
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
const avatarRenderCalls: Array<Record<string, unknown>> = []
|
|
18
|
+
vi.mock('./Avatar', () => ({
|
|
19
|
+
Avatar: (props: Record<string, unknown>) => {
|
|
20
|
+
avatarRenderCalls.push({ ...props })
|
|
21
|
+
return <div data-testid="avatar" />
|
|
22
|
+
},
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
vi.mock('./ChannelActionsMenu', () => ({
|
|
26
|
+
ChannelActionsMenu: () => <div data-testid="channel-actions-menu" />,
|
|
27
|
+
}))
|
|
28
|
+
|
|
29
|
+
vi.mock('../hooks/useChannelStar', () => ({
|
|
30
|
+
useChannelStar: () => false,
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
const createChannel = ({
|
|
34
|
+
customerTag,
|
|
35
|
+
isFollower = true,
|
|
36
|
+
participantUser = { id: 'linker-1', name: 'Linker' },
|
|
37
|
+
hasState = true,
|
|
38
|
+
hasData = true,
|
|
39
|
+
}: {
|
|
40
|
+
customerTag?: unknown
|
|
41
|
+
isFollower?: boolean
|
|
42
|
+
participantUser?: { id?: string; name?: string; image?: string } | undefined
|
|
43
|
+
hasState?: boolean
|
|
44
|
+
hasData?: boolean
|
|
45
|
+
} = {}) =>
|
|
46
|
+
({
|
|
47
|
+
id: 'channel-1',
|
|
48
|
+
type: 'messaging',
|
|
49
|
+
data: hasData ? { customer_tag: customerTag } : undefined,
|
|
50
|
+
state: hasState
|
|
51
|
+
? {
|
|
52
|
+
members: {
|
|
53
|
+
me: { user: { id: 'visitor-1', name: 'Visitor' } },
|
|
54
|
+
other: { user: participantUser, is_follower: isFollower },
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: undefined,
|
|
58
|
+
}) as unknown as Channel
|
|
59
|
+
|
|
60
|
+
const CONNECTED_CLIENT = { userID: 'visitor-1' }
|
|
61
|
+
|
|
62
|
+
const renderHeader = (channel: Channel, ...client: [unknown?]) => {
|
|
63
|
+
activeChannel = channel
|
|
64
|
+
activeClient = client.length > 0 ? client[0] : CONNECTED_CLIENT
|
|
65
|
+
return renderWithProviders(<ChannelHeader showBackButton={false} />)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const paidBadge = () => screen.queryByLabelText('Paid customer')
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
avatarRenderCalls.length = 0
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
describe('ChannelHeader paid-customer badge', () => {
|
|
75
|
+
it.each([
|
|
76
|
+
['the canonical tag', 'CUSTOMER_PAID'],
|
|
77
|
+
['a space-separated tag', 'customer paid'],
|
|
78
|
+
['a hyphen-separated tag', 'Customer-Paid'],
|
|
79
|
+
['a tag padded with whitespace', ' customer paid '],
|
|
80
|
+
['a tag with repeated separators', 'customer - paid'],
|
|
81
|
+
])('badges a follower carrying %s', (_label, customerTag) => {
|
|
82
|
+
renderHeader(createChannel({ customerTag }))
|
|
83
|
+
|
|
84
|
+
expect(paidBadge()).not.toBeNull()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not badge a follower whose tag is a different string', () => {
|
|
88
|
+
renderHeader(createChannel({ customerTag: 'CUSTOMER_FREE' }))
|
|
89
|
+
|
|
90
|
+
expect(paidBadge()).toBeNull()
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it('does not badge a follower whose tag is a partial match', () => {
|
|
94
|
+
renderHeader(createChannel({ customerTag: 'customer_paid_trial' }))
|
|
95
|
+
|
|
96
|
+
expect(paidBadge()).toBeNull()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it.each([
|
|
100
|
+
['a number', 42],
|
|
101
|
+
['an object', { value: 'CUSTOMER_PAID' }],
|
|
102
|
+
['undefined', undefined],
|
|
103
|
+
])(
|
|
104
|
+
'ignores a non-string tag (%s) without throwing',
|
|
105
|
+
(_label, customerTag) => {
|
|
106
|
+
renderHeader(createChannel({ customerTag }))
|
|
107
|
+
|
|
108
|
+
expect(paidBadge()).toBeNull()
|
|
109
|
+
expect(screen.getByText('Linker')).toBeInTheDocument()
|
|
110
|
+
}
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
it('does not badge a paid tag when the participant is not a follower', () => {
|
|
114
|
+
renderHeader(
|
|
115
|
+
createChannel({ customerTag: 'CUSTOMER_PAID', isFollower: false })
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
expect(paidBadge()).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
describe('ChannelHeader participant resolution', () => {
|
|
123
|
+
it('renders before the chat client is connected, with nobody excluded as “me”', () => {
|
|
124
|
+
renderHeader(createChannel(), undefined)
|
|
125
|
+
|
|
126
|
+
expect(screen.getByText('Visitor')).toBeInTheDocument()
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('renders a channel whose state has not hydrated yet', () => {
|
|
130
|
+
renderHeader(
|
|
131
|
+
createChannel({ hasState: false, customerTag: 'CUSTOMER_PAID' })
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
expect(screen.getByText('Unknown member')).toBeInTheDocument()
|
|
135
|
+
expect(paidBadge()).toBeNull()
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('renders a channel with no custom data', () => {
|
|
139
|
+
renderHeader(createChannel({ hasData: false }))
|
|
140
|
+
|
|
141
|
+
expect(screen.getByText('Linker')).toBeInTheDocument()
|
|
142
|
+
expect(paidBadge()).toBeNull()
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('keys the avatar off the channel and drops the image when members are absent', () => {
|
|
146
|
+
renderHeader(createChannel({ hasState: false }))
|
|
147
|
+
|
|
148
|
+
expect(avatarRenderCalls.at(-1)?.id).toBe('channel-1')
|
|
149
|
+
expect(avatarRenderCalls.at(-1)?.image).toBeUndefined()
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('keys the avatar on the participant, falling back to the channel id', () => {
|
|
153
|
+
renderHeader(createChannel())
|
|
154
|
+
expect(avatarRenderCalls.at(-1)?.id).toBe('linker-1')
|
|
155
|
+
|
|
156
|
+
avatarRenderCalls.length = 0
|
|
157
|
+
renderHeader(createChannel({ participantUser: { name: 'Nameless' } }))
|
|
158
|
+
expect(avatarRenderCalls.at(-1)?.id).toBe('channel-1')
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders, screen } from '../../../../test/utils'
|
|
5
|
+
|
|
6
|
+
import BubbleShell from './BubbleShell'
|
|
7
|
+
|
|
8
|
+
const renderShell = (
|
|
9
|
+
props: Partial<React.ComponentProps<typeof BubbleShell>> = {}
|
|
10
|
+
): HTMLElement => {
|
|
11
|
+
renderWithProviders(<BubbleShell variant="sent" text="body" {...props} />)
|
|
12
|
+
// eslint-disable-next-line testing-library/no-node-access -- the bubble box wraps the text and carries the theme contract; it has no semantic query.
|
|
13
|
+
return screen.getByText(props.text ?? 'body').parentElement as HTMLElement
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('BubbleShell', () => {
|
|
17
|
+
it('inks each variant from its own theme variable, with the canonical fallback', () => {
|
|
18
|
+
expect(renderShell({ variant: 'sent', text: 'mine' }).style.color).toBe(
|
|
19
|
+
'var(--str-chat__own-message-bubble-color, #fff)'
|
|
20
|
+
)
|
|
21
|
+
expect(
|
|
22
|
+
renderShell({ variant: 'received', text: 'theirs' }).style.color
|
|
23
|
+
).toBe('var(--str-chat__message-bubble-color, #000000)')
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('rounds the bubble from the theme variable rather than a hard-coded radius', () => {
|
|
27
|
+
expect(renderShell().style.borderRadius).toBe(
|
|
28
|
+
'var(--str-chat__message-bubble-border-radius, 1.5rem)'
|
|
29
|
+
)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('establishes the stacking context the z-index:-1 tail paints inside', () => {
|
|
33
|
+
const bubble = renderShell()
|
|
34
|
+
|
|
35
|
+
// `relative` + `isolation: isolate` keep the z-index:-1 tail behind the
|
|
36
|
+
// bubble but in front of the page.
|
|
37
|
+
expect(bubble.className).toMatch(/(?:^|\s)relative(?:\s|$)/)
|
|
38
|
+
expect(bubble.style.isolation).toBe('isolate')
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('keeps the channel-matched typography metrics so previews stay pixel-identical', () => {
|
|
42
|
+
// Mirrors the in-channel bubble metrics in `styles.css`.
|
|
43
|
+
const className = renderShell().className
|
|
44
|
+
|
|
45
|
+
expect(className).toMatch(/(?:^|\s)text-\[0\.875rem\](?:\s|$)/)
|
|
46
|
+
expect(className).toMatch(/(?:^|\s)leading-\[1\.3125rem\](?:\s|$)/)
|
|
47
|
+
expect(className).toMatch(/(?:^|\s)tracking-\[0\.14px\](?:\s|$)/)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { describe, expect, it } from 'vitest'
|
|
3
|
+
|
|
4
|
+
import { renderWithProviders } from '../../../../test/utils'
|
|
5
|
+
import type { MessageBubbleVariant } from '../../types'
|
|
6
|
+
|
|
7
|
+
import { BubbleTail } from './bubbleTail'
|
|
8
|
+
|
|
9
|
+
// The canonical 20×23 tail silhouette, also drawn by `styles.css` in-channel.
|
|
10
|
+
const EXPECTED_TAIL_MASK =
|
|
11
|
+
"url(\"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='20' height='23' fill='none'%3E%3Cpath fill='black' d='M0 0H15C15 10.1934 15.859 15.2345 19.168 21.4893C19.527 22.168 19.039 22.9917 18.274 22.9178C5.176 21.6506 0.32 6.0737 0 0Z'/%3E%3C/svg%3E\") no-repeat center / 100% 100%"
|
|
12
|
+
|
|
13
|
+
const CORNER_OFFSET = 'calc(-1 * 0.3125rem)'
|
|
14
|
+
|
|
15
|
+
const renderTail = (variant: MessageBubbleVariant): HTMLElement => {
|
|
16
|
+
const { container } = renderWithProviders(<BubbleTail variant={variant} />)
|
|
17
|
+
// eslint-disable-next-line testing-library/no-node-access -- the tail is aria-hidden by design, so it has no semantic query.
|
|
18
|
+
return container.firstElementChild as HTMLElement
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('BubbleTail', () => {
|
|
22
|
+
it('pins the sent tail to the bubble’s bottom-right corner, unmirrored', () => {
|
|
23
|
+
const tail = renderTail('sent')
|
|
24
|
+
|
|
25
|
+
expect(tail.style.insetInlineEnd).toBe(CORNER_OFFSET)
|
|
26
|
+
expect(tail.style.insetInlineStart).toBe('')
|
|
27
|
+
expect(tail.style.transform).toBe('')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('pins the received tail to the bottom-left corner and mirrors it', () => {
|
|
31
|
+
const tail = renderTail('received')
|
|
32
|
+
|
|
33
|
+
expect(tail.style.insetInlineStart).toBe(CORNER_OFFSET)
|
|
34
|
+
expect(tail.style.insetInlineEnd).toBe('')
|
|
35
|
+
expect(tail.style.transform).toBe('scaleX(-1)')
|
|
36
|
+
expect(tail.style.transformOrigin).toBe('center')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('tints each tail with its own bubble’s surface variable', () => {
|
|
40
|
+
expect(renderTail('sent').style.backgroundColor).toBe(
|
|
41
|
+
'var(--str-chat__own-message-bubble-background-color, #1e2330)'
|
|
42
|
+
)
|
|
43
|
+
expect(renderTail('received').style.backgroundColor).toBe(
|
|
44
|
+
'var(--str-chat__message-bubble-background-color, #f1f0ee)'
|
|
45
|
+
)
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('paints both variants with the one canonical tail mask', () => {
|
|
49
|
+
expect(renderTail('sent').style.mask).toBe(EXPECTED_TAIL_MASK)
|
|
50
|
+
expect(renderTail('received').style.mask).toBe(EXPECTED_TAIL_MASK)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('is decorative: hidden from assistive tech and not hit-testable', () => {
|
|
54
|
+
const tail = renderTail('sent')
|
|
55
|
+
|
|
56
|
+
expect(tail.getAttribute('aria-hidden')).toBe('true')
|
|
57
|
+
expect(tail.className).toMatch(/(?:^|\s)pointer-events-none(?:\s|$)/)
|
|
58
|
+
})
|
|
59
|
+
})
|