@linktr.ee/messaging-react 1.40.2 → 2.0.1
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-A0lkei-S.js → Card-BKP9ml9O.js} +2 -2
- package/dist/{Card-A0lkei-S.js.map → Card-BKP9ml9O.js.map} +1 -1
- package/dist/{Card-DXoAKkv0.js → Card-Bk_4lVzP.js} +2 -2
- package/dist/{Card-DXoAKkv0.js.map → Card-Bk_4lVzP.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/index-Bex7eg3v.js +3092 -0
- package/dist/index-Bex7eg3v.js.map +1 -0
- package/dist/index.d.ts +22 -2
- package/dist/index.js +12 -10
- package/package.json +2 -2
- package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +2 -14
- package/src/components/ChannelInfoDialog/index.tsx +4 -8
- package/src/components/ChannelList/ChannelListContext.tsx +2 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +14 -3
- package/src/components/ChannelList/index.tsx +9 -1
- package/src/components/ChannelView.test.tsx +11 -0
- package/src/components/ChannelView.tsx +44 -33
- package/src/components/CustomMessage/index.tsx +22 -4
- package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
- package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
- package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
- package/src/components/CustomTypingIndicator/index.tsx +101 -37
- package/src/components/MessagingShell/index.tsx +4 -4
- package/src/index.ts +13 -2
- package/src/stories/mocks.tsx +2 -9
- package/src/styles.css +7 -0
- package/src/types.ts +11 -1
- package/src/utils/getMessageDisplayText.test.ts +44 -0
- package/src/utils/getMessageDisplayText.ts +27 -0
- package/dist/index-B_PLgcDi.js +0 -2994
- package/dist/index-B_PLgcDi.js.map +0 -1
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { Event } from 'stream-chat'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { renderWithProviders, screen } from '../../test/utils'
|
|
6
|
+
|
|
7
|
+
import { DmAgentEnabledContext } from './DmAgentContext'
|
|
8
|
+
|
|
9
|
+
const visitor = { id: 'visitor-1', name: 'Visitor' }
|
|
10
|
+
const agent = { id: 'creator-1', name: 'Creator', image: 'agent.png' }
|
|
11
|
+
|
|
12
|
+
let typingContext: { typing: Record<string, Event> } = { typing: {} }
|
|
13
|
+
let aiStateContext: { aiState: string } = { aiState: 'AI_STATE_IDLE' }
|
|
14
|
+
let channelStateContext: {
|
|
15
|
+
channel: {
|
|
16
|
+
state: { members: Record<string, { user: typeof visitor | typeof agent }> }
|
|
17
|
+
}
|
|
18
|
+
channelConfig: { typing_events: boolean }
|
|
19
|
+
thread: undefined
|
|
20
|
+
} = {
|
|
21
|
+
channel: {
|
|
22
|
+
state: {
|
|
23
|
+
members: {
|
|
24
|
+
[visitor.id]: { user: visitor },
|
|
25
|
+
[agent.id]: { user: agent },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
channelConfig: { typing_events: true },
|
|
30
|
+
thread: undefined,
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
vi.mock('stream-chat-react', () => ({
|
|
34
|
+
AIStates: {
|
|
35
|
+
Error: 'AI_STATE_ERROR',
|
|
36
|
+
ExternalSources: 'AI_STATE_EXTERNAL_SOURCES',
|
|
37
|
+
Generating: 'AI_STATE_GENERATING',
|
|
38
|
+
Idle: 'AI_STATE_IDLE',
|
|
39
|
+
Stop: 'AI_STATE_STOP',
|
|
40
|
+
Thinking: 'AI_STATE_THINKING',
|
|
41
|
+
},
|
|
42
|
+
useAIState: () => aiStateContext,
|
|
43
|
+
useChannelStateContext: () => channelStateContext,
|
|
44
|
+
useChatContext: () => ({ client: { user: visitor } }),
|
|
45
|
+
useTypingContext: () => typingContext,
|
|
46
|
+
}))
|
|
47
|
+
|
|
48
|
+
vi.mock('../Avatar', () => ({
|
|
49
|
+
Avatar: ({ name, id }: { name: string; id: string }) => (
|
|
50
|
+
<div data-testid="avatar" data-id={id}>
|
|
51
|
+
{name}
|
|
52
|
+
</div>
|
|
53
|
+
),
|
|
54
|
+
}))
|
|
55
|
+
|
|
56
|
+
const importIndicator = async () => (await import('.')).default
|
|
57
|
+
|
|
58
|
+
const renderIndicator = async (dmAgentEnabled = true) => {
|
|
59
|
+
const CustomTypingIndicator = await importIndicator()
|
|
60
|
+
return renderWithProviders(
|
|
61
|
+
<DmAgentEnabledContext.Provider value={dmAgentEnabled}>
|
|
62
|
+
<CustomTypingIndicator />
|
|
63
|
+
</DmAgentEnabledContext.Provider>
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
describe('CustomTypingIndicator', () => {
|
|
68
|
+
beforeEach(() => {
|
|
69
|
+
typingContext = { typing: {} }
|
|
70
|
+
aiStateContext = { aiState: 'AI_STATE_IDLE' }
|
|
71
|
+
channelStateContext = {
|
|
72
|
+
channel: {
|
|
73
|
+
state: {
|
|
74
|
+
members: {
|
|
75
|
+
[visitor.id]: { user: visitor },
|
|
76
|
+
[agent.id]: { user: agent },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
channelConfig: { typing_events: true },
|
|
81
|
+
thread: undefined,
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('renders nothing when idle and no typers', async () => {
|
|
86
|
+
await renderIndicator()
|
|
87
|
+
expect(screen.queryByTestId('typing-indicator')).toBeNull()
|
|
88
|
+
expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('renders the human typing bubble when someone else is typing', async () => {
|
|
92
|
+
typingContext = {
|
|
93
|
+
typing: {
|
|
94
|
+
[agent.id]: {
|
|
95
|
+
type: 'typing.start',
|
|
96
|
+
user: agent,
|
|
97
|
+
parent_id: undefined,
|
|
98
|
+
} as Event,
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
await renderIndicator()
|
|
102
|
+
expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
|
|
103
|
+
expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('hides the human typing bubble when typing_events is disabled', async () => {
|
|
107
|
+
channelStateContext.channelConfig.typing_events = false
|
|
108
|
+
typingContext = {
|
|
109
|
+
typing: {
|
|
110
|
+
[agent.id]: {
|
|
111
|
+
type: 'typing.start',
|
|
112
|
+
user: agent,
|
|
113
|
+
parent_id: undefined,
|
|
114
|
+
} as Event,
|
|
115
|
+
},
|
|
116
|
+
}
|
|
117
|
+
await renderIndicator()
|
|
118
|
+
expect(screen.queryByTestId('typing-indicator')).toBeNull()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('renders the AI bubble when the agent is thinking', async () => {
|
|
122
|
+
aiStateContext = { aiState: 'AI_STATE_THINKING' }
|
|
123
|
+
await renderIndicator()
|
|
124
|
+
expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
|
|
125
|
+
expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', agent.id)
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('renders the AI bubble when the agent is generating', async () => {
|
|
129
|
+
aiStateContext = { aiState: 'AI_STATE_GENERATING' }
|
|
130
|
+
await renderIndicator()
|
|
131
|
+
expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('renders the AI bubble when the agent is checking external sources', async () => {
|
|
135
|
+
aiStateContext = { aiState: 'AI_STATE_EXTERNAL_SOURCES' }
|
|
136
|
+
await renderIndicator()
|
|
137
|
+
expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('does not render the AI bubble inside a thread list', async () => {
|
|
141
|
+
aiStateContext = { aiState: 'AI_STATE_GENERATING' }
|
|
142
|
+
const CustomTypingIndicator = await importIndicator()
|
|
143
|
+
renderWithProviders(
|
|
144
|
+
<DmAgentEnabledContext.Provider value={true}>
|
|
145
|
+
<CustomTypingIndicator threadList />
|
|
146
|
+
</DmAgentEnabledContext.Provider>
|
|
147
|
+
)
|
|
148
|
+
expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('renders the AI bubble even when typing_events is disabled', async () => {
|
|
152
|
+
channelStateContext.channelConfig.typing_events = false
|
|
153
|
+
aiStateContext = { aiState: 'AI_STATE_THINKING' }
|
|
154
|
+
await renderIndicator()
|
|
155
|
+
expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('falls back to a default avatar id when no other channel member exists', async () => {
|
|
159
|
+
channelStateContext.channel.state.members = {
|
|
160
|
+
[visitor.id]: { user: visitor },
|
|
161
|
+
}
|
|
162
|
+
aiStateContext = { aiState: 'AI_STATE_GENERATING' }
|
|
163
|
+
await renderIndicator()
|
|
164
|
+
expect(screen.getByTestId('typing-indicator-ai')).toBeInTheDocument()
|
|
165
|
+
expect(screen.getByTestId('avatar')).toHaveAttribute('data-id', 'ai-agent')
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it('does not render the AI bubble when dmAgentEnabled is false', async () => {
|
|
169
|
+
aiStateContext = { aiState: 'AI_STATE_GENERATING' }
|
|
170
|
+
await renderIndicator(false)
|
|
171
|
+
expect(screen.queryByTestId('typing-indicator-ai')).toBeNull()
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('still renders the human bubble when dmAgentEnabled is false', async () => {
|
|
175
|
+
typingContext = {
|
|
176
|
+
typing: {
|
|
177
|
+
[agent.id]: {
|
|
178
|
+
type: 'typing.start',
|
|
179
|
+
user: agent,
|
|
180
|
+
parent_id: undefined,
|
|
181
|
+
} as Event,
|
|
182
|
+
},
|
|
183
|
+
}
|
|
184
|
+
await renderIndicator(false)
|
|
185
|
+
expect(screen.getByTestId('typing-indicator')).toBeInTheDocument()
|
|
186
|
+
})
|
|
187
|
+
})
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import React from 'react'
|
|
2
|
-
import type { Event } from 'stream-chat'
|
|
1
|
+
import React, { useContext } from 'react'
|
|
2
|
+
import type { Event, UserResponse } from 'stream-chat'
|
|
3
3
|
import {
|
|
4
|
+
AIStates,
|
|
5
|
+
useAIState,
|
|
4
6
|
useChannelStateContext,
|
|
5
7
|
useChatContext,
|
|
6
8
|
useTypingContext,
|
|
@@ -8,6 +10,8 @@ import {
|
|
|
8
10
|
|
|
9
11
|
import { Avatar } from '../Avatar'
|
|
10
12
|
|
|
13
|
+
import { DmAgentEnabledContext } from './DmAgentContext'
|
|
14
|
+
|
|
11
15
|
interface CustomTypingIndicatorProps {
|
|
12
16
|
threadList?: boolean
|
|
13
17
|
}
|
|
@@ -25,10 +29,38 @@ const Circle = ({ cx, index }: { cx: string; index: number }) => (
|
|
|
25
29
|
</circle>
|
|
26
30
|
)
|
|
27
31
|
|
|
32
|
+
const AI_ACTIVE_STATES = new Set<string>([
|
|
33
|
+
AIStates.Thinking,
|
|
34
|
+
AIStates.Generating,
|
|
35
|
+
AIStates.ExternalSources,
|
|
36
|
+
])
|
|
37
|
+
|
|
28
38
|
const CustomTypingIndicator = ({ threadList }: CustomTypingIndicatorProps) => {
|
|
29
39
|
const { channel, channelConfig, thread } = useChannelStateContext()
|
|
30
40
|
const { client } = useChatContext()
|
|
31
41
|
const { typing = {} } = useTypingContext()
|
|
42
|
+
const { aiState } = useAIState(channel)
|
|
43
|
+
const dmAgentEnabled = useContext(DmAgentEnabledContext)
|
|
44
|
+
|
|
45
|
+
// Show the AI indicator whenever the consumer agent is producing a reply.
|
|
46
|
+
// This event stream is independent of `typing.start`/`typing.stop`, so it is
|
|
47
|
+
// intentionally NOT gated by `channelConfig.typing_events`. Gate strictly on
|
|
48
|
+
// `dmAgentEnabled` so stale or off-surface ai_indicator events never surface
|
|
49
|
+
// the bubble on channels where the agent is not active.
|
|
50
|
+
const isAiActive =
|
|
51
|
+
!threadList && dmAgentEnabled && AI_ACTIVE_STATES.has(aiState)
|
|
52
|
+
|
|
53
|
+
if (isAiActive) {
|
|
54
|
+
const agentUser = findOtherChannelUser(channel, client.user?.id)
|
|
55
|
+
return (
|
|
56
|
+
<TypingBubble
|
|
57
|
+
avatarId={agentUser?.id ?? 'ai-agent'}
|
|
58
|
+
avatarName={agentUser?.name ?? agentUser?.id ?? 'Agent'}
|
|
59
|
+
avatarImage={agentUser?.image}
|
|
60
|
+
testId="typing-indicator-ai"
|
|
61
|
+
/>
|
|
62
|
+
)
|
|
63
|
+
}
|
|
32
64
|
|
|
33
65
|
if (channelConfig?.typing_events === false) {
|
|
34
66
|
return null
|
|
@@ -59,43 +91,75 @@ const CustomTypingIndicator = ({ threadList }: CustomTypingIndicatorProps) => {
|
|
|
59
91
|
? channel.state.members[typingUser.id].user
|
|
60
92
|
: undefined
|
|
61
93
|
|
|
62
|
-
const avatarId = typingUser?.id ?? memberUser?.id ?? 'typing-user'
|
|
63
|
-
const avatarName =
|
|
64
|
-
typingUser?.name ?? memberUser?.name ?? typingUser?.id ?? 'Typing user'
|
|
65
|
-
const avatarImage = typingUser?.image ?? memberUser?.image
|
|
66
|
-
|
|
67
94
|
return (
|
|
68
|
-
<
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
name={avatarName}
|
|
77
|
-
image={avatarImage}
|
|
78
|
-
size={24}
|
|
79
|
-
shape="circle"
|
|
80
|
-
/>
|
|
81
|
-
</div>
|
|
82
|
-
|
|
83
|
-
<div className="px-4 py-3 rounded-lg bg-[#E9EAED] h-12 flex flex-col justify-end">
|
|
84
|
-
<svg
|
|
85
|
-
aria-hidden="true"
|
|
86
|
-
className="block overflow-visible mb-[0.2rem]"
|
|
87
|
-
viewBox="0 0 32 8"
|
|
88
|
-
width="32"
|
|
89
|
-
height="8"
|
|
90
|
-
overflow="visible"
|
|
91
|
-
>
|
|
92
|
-
<Circle cx="4" index={0} />
|
|
93
|
-
<Circle cx="16" index={1} />
|
|
94
|
-
<Circle cx="28" index={2} />
|
|
95
|
-
</svg>
|
|
96
|
-
</div>
|
|
97
|
-
</div>
|
|
95
|
+
<TypingBubble
|
|
96
|
+
avatarId={typingUser?.id ?? memberUser?.id ?? 'typing-user'}
|
|
97
|
+
avatarName={
|
|
98
|
+
typingUser?.name ?? memberUser?.name ?? typingUser?.id ?? 'Typing user'
|
|
99
|
+
}
|
|
100
|
+
avatarImage={typingUser?.image ?? memberUser?.image}
|
|
101
|
+
testId="typing-indicator"
|
|
102
|
+
/>
|
|
98
103
|
)
|
|
99
104
|
}
|
|
100
105
|
|
|
106
|
+
const TypingBubble = ({
|
|
107
|
+
avatarId,
|
|
108
|
+
avatarName,
|
|
109
|
+
avatarImage,
|
|
110
|
+
testId,
|
|
111
|
+
}: {
|
|
112
|
+
avatarId: string
|
|
113
|
+
avatarName: string
|
|
114
|
+
avatarImage?: string | null
|
|
115
|
+
testId: string
|
|
116
|
+
}) => (
|
|
117
|
+
<div
|
|
118
|
+
className="str-chat__typing-indicator !items-end !bg-transparent"
|
|
119
|
+
data-testid={testId}
|
|
120
|
+
style={{ insetInlineStart: 0, insetInlineEnd: 'auto' }}
|
|
121
|
+
>
|
|
122
|
+
<div className="shrink-0" aria-hidden="true">
|
|
123
|
+
<Avatar
|
|
124
|
+
id={avatarId}
|
|
125
|
+
name={avatarName}
|
|
126
|
+
image={avatarImage ?? undefined}
|
|
127
|
+
size={24}
|
|
128
|
+
shape="circle"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
|
|
132
|
+
<div className="px-4 py-3 rounded-lg bg-[#E9EAED] h-12 flex flex-col justify-end">
|
|
133
|
+
<svg
|
|
134
|
+
aria-hidden="true"
|
|
135
|
+
className="block overflow-visible mb-[0.2rem]"
|
|
136
|
+
viewBox="0 0 32 8"
|
|
137
|
+
width="32"
|
|
138
|
+
height="8"
|
|
139
|
+
overflow="visible"
|
|
140
|
+
>
|
|
141
|
+
<Circle cx="4" index={0} />
|
|
142
|
+
<Circle cx="16" index={1} />
|
|
143
|
+
<Circle cx="28" index={2} />
|
|
144
|
+
</svg>
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
type ChannelLike = ReturnType<typeof useChannelStateContext>['channel']
|
|
150
|
+
|
|
151
|
+
function findOtherChannelUser(
|
|
152
|
+
channel: ChannelLike,
|
|
153
|
+
selfId: string | undefined
|
|
154
|
+
): UserResponse | undefined {
|
|
155
|
+
const members = channel?.state?.members ?? {}
|
|
156
|
+
for (const member of Object.values(members)) {
|
|
157
|
+
const memberUser = member?.user
|
|
158
|
+
if (memberUser && memberUser.id !== selfId) {
|
|
159
|
+
return memberUser
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
101
165
|
export default CustomTypingIndicator
|
|
@@ -35,6 +35,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
35
35
|
onMessageSent,
|
|
36
36
|
showStarButton = false,
|
|
37
37
|
chatbotVotingEnabled = false,
|
|
38
|
+
viewerLanguage,
|
|
38
39
|
renderMessagePreview,
|
|
39
40
|
renderChannelBanner,
|
|
40
41
|
customProfileContent,
|
|
@@ -60,9 +61,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
60
61
|
string | null
|
|
61
62
|
>(null)
|
|
62
63
|
|
|
63
|
-
const {
|
|
64
|
-
showDeleteConversation = true,
|
|
65
|
-
} = capabilities
|
|
64
|
+
const { showDeleteConversation = true } = capabilities
|
|
66
65
|
|
|
67
66
|
// Create default filters and merge with provided filters
|
|
68
67
|
const channelFilters = React.useMemo(() => {
|
|
@@ -194,7 +193,6 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
194
193
|
const channel = await service.startChannelWithParticipant({
|
|
195
194
|
id: initialParticipantData.id,
|
|
196
195
|
name: initialParticipantData.name,
|
|
197
|
-
email: initialParticipantData.email,
|
|
198
196
|
phone: initialParticipantData.phone,
|
|
199
197
|
})
|
|
200
198
|
|
|
@@ -375,6 +373,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
375
373
|
channelRenderFilterFn={channelRenderFilterFn}
|
|
376
374
|
customEmptyStateIndicator={channelListCustomEmptyStateIndicator}
|
|
377
375
|
renderMessagePreview={renderMessagePreview}
|
|
376
|
+
viewerLanguage={viewerLanguage}
|
|
378
377
|
/>
|
|
379
378
|
</div>
|
|
380
379
|
|
|
@@ -418,6 +417,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
|
418
417
|
onMessageSent={onMessageSent}
|
|
419
418
|
showStarButton={showStarButton}
|
|
420
419
|
chatbotVotingEnabled={chatbotVotingEnabled}
|
|
420
|
+
viewerLanguage={viewerLanguage}
|
|
421
421
|
customProfileContent={customProfileContent}
|
|
422
422
|
customChannelActions={customChannelActions}
|
|
423
423
|
renderMessage={renderMessage}
|
package/src/index.ts
CHANGED
|
@@ -18,7 +18,10 @@ export {
|
|
|
18
18
|
resolveLinkAttachment,
|
|
19
19
|
resolveMediaFromMessage,
|
|
20
20
|
} from './components/MediaMessage'
|
|
21
|
-
export type {
|
|
21
|
+
export type {
|
|
22
|
+
MediaMessageProps,
|
|
23
|
+
MediaMessageResolved,
|
|
24
|
+
} from './components/MediaMessage'
|
|
22
25
|
|
|
23
26
|
// Providers
|
|
24
27
|
export { MessagingProvider } from './providers/MessagingProvider'
|
|
@@ -31,6 +34,10 @@ export { useCustomMessage } from './components/CustomMessage/context'
|
|
|
31
34
|
|
|
32
35
|
// Utils
|
|
33
36
|
export { formatRelativeTime } from './utils/formatRelativeTime'
|
|
37
|
+
export {
|
|
38
|
+
getMessageDisplayText,
|
|
39
|
+
normalizeLanguageCode,
|
|
40
|
+
} from './utils/getMessageDisplayText'
|
|
34
41
|
|
|
35
42
|
// Types
|
|
36
43
|
export type {
|
|
@@ -45,7 +52,11 @@ export type {
|
|
|
45
52
|
export type { MessageMetadata } from './stream-custom-data'
|
|
46
53
|
export type { AvatarProps } from './components/Avatar'
|
|
47
54
|
export type { ActionButtonProps } from './components/ActionButton'
|
|
48
|
-
export type {
|
|
55
|
+
export type {
|
|
56
|
+
CreatorCardProps,
|
|
57
|
+
VisitorCardProps,
|
|
58
|
+
LockedAttachmentContextValue,
|
|
59
|
+
} from './components/LockedAttachment'
|
|
49
60
|
export type { CustomMessageRegistry } from './components/CustomMessage/context'
|
|
50
61
|
export type { AttachmentSourceType } from './components/AttachmentCard/utils/mimeType'
|
|
51
62
|
export type { Faq, FaqListProps } from './components/FaqList'
|
package/src/stories/mocks.tsx
CHANGED
|
@@ -110,31 +110,26 @@ export const mockParticipants = [
|
|
|
110
110
|
{
|
|
111
111
|
id: 'participant-1',
|
|
112
112
|
name: 'Alice Johnson',
|
|
113
|
-
email: 'alice@example.com',
|
|
114
113
|
image: 'https://i.pravatar.cc/150?img=2',
|
|
115
114
|
},
|
|
116
115
|
{
|
|
117
116
|
id: 'participant-2',
|
|
118
117
|
name: 'Bob Smith',
|
|
119
|
-
email: 'bob@example.com',
|
|
120
118
|
image: 'https://i.pravatar.cc/150?img=3',
|
|
121
119
|
},
|
|
122
120
|
{
|
|
123
121
|
id: 'participant-3',
|
|
124
122
|
name: 'Carol Williams',
|
|
125
|
-
email: 'carol@example.com',
|
|
126
123
|
image: 'https://i.pravatar.cc/150?img=4',
|
|
127
124
|
},
|
|
128
125
|
{
|
|
129
126
|
id: 'participant-4',
|
|
130
127
|
name: 'David Brown',
|
|
131
|
-
email: 'david@example.com',
|
|
132
128
|
image: 'https://i.pravatar.cc/150?img=5',
|
|
133
129
|
},
|
|
134
130
|
{
|
|
135
131
|
id: 'participant-5',
|
|
136
132
|
name: 'Emma Davis',
|
|
137
|
-
email: 'emma@example.com',
|
|
138
133
|
image: 'https://i.pravatar.cc/150?img=6',
|
|
139
134
|
},
|
|
140
135
|
]
|
|
@@ -143,10 +138,8 @@ export const mockParticipants = [
|
|
|
143
138
|
export const mockParticipantSource = {
|
|
144
139
|
loadParticipants: async (options?: { search?: string; limit?: number }) => {
|
|
145
140
|
const searchTerm = options?.search || ''
|
|
146
|
-
const filtered = mockParticipants.filter(
|
|
147
|
-
(
|
|
148
|
-
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
149
|
-
p.email.toLowerCase().includes(searchTerm.toLowerCase())
|
|
141
|
+
const filtered = mockParticipants.filter((p) =>
|
|
142
|
+
p.name.toLowerCase().includes(searchTerm.toLowerCase())
|
|
150
143
|
)
|
|
151
144
|
return {
|
|
152
145
|
participants: filtered,
|
package/src/styles.css
CHANGED
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
/* Stream Chat base styles */
|
|
2
2
|
@import 'stream-chat-react/dist/css/v2/index.css';
|
|
3
3
|
|
|
4
|
+
/* Inherit the host's font instead of stream-chat-react's hardcoded system stack.
|
|
5
|
+
In admin (federation host) this resolves to Link Sans; on linktr.ee profile
|
|
6
|
+
pages it resolves to the creator's profile font. */
|
|
7
|
+
.str-chat {
|
|
8
|
+
--str-chat__font-family: inherit;
|
|
9
|
+
}
|
|
10
|
+
|
|
4
11
|
/* Dialog component styles - used by messaging components */
|
|
5
12
|
/* Note: Dialogs get moved to the top layer when opened with .showModal() */
|
|
6
13
|
.mes-dialog {
|
package/src/types.ts
CHANGED
|
@@ -25,7 +25,6 @@ export type { LockedAttachmentSource } from './components/LockedAttachment'
|
|
|
25
25
|
export interface Participant {
|
|
26
26
|
id: string
|
|
27
27
|
name: string
|
|
28
|
-
email?: string
|
|
29
28
|
image?: string
|
|
30
29
|
username?: string
|
|
31
30
|
phone?: string
|
|
@@ -95,6 +94,11 @@ export interface ChannelListProps {
|
|
|
95
94
|
message: LocalMessage | undefined,
|
|
96
95
|
defaultPreview?: string
|
|
97
96
|
) => React.ReactNode
|
|
97
|
+
/**
|
|
98
|
+
* Language code used to pick translated message text from Stream Chat i18n.
|
|
99
|
+
* Falls back to message.text when no matching translation exists.
|
|
100
|
+
*/
|
|
101
|
+
viewerLanguage?: string
|
|
98
102
|
}
|
|
99
103
|
|
|
100
104
|
/**
|
|
@@ -173,6 +177,11 @@ export interface ChannelViewProps {
|
|
|
173
177
|
* Defaults to false.
|
|
174
178
|
*/
|
|
175
179
|
chatbotVotingEnabled?: boolean
|
|
180
|
+
/**
|
|
181
|
+
* Language code used to pick translated message text from Stream Chat i18n.
|
|
182
|
+
* Falls back to message.text when no matching translation exists.
|
|
183
|
+
*/
|
|
184
|
+
viewerLanguage?: string
|
|
176
185
|
|
|
177
186
|
/**
|
|
178
187
|
* Custom render function for a banner/card component that renders
|
|
@@ -244,6 +253,7 @@ export type ChannelViewPassthroughProps = Pick<
|
|
|
244
253
|
| 'onMessageSent'
|
|
245
254
|
| 'showStarButton'
|
|
246
255
|
| 'chatbotVotingEnabled'
|
|
256
|
+
| 'viewerLanguage'
|
|
247
257
|
| 'renderChannelBanner'
|
|
248
258
|
| 'customProfileContent'
|
|
249
259
|
| 'customChannelActions'
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
getMessageDisplayText,
|
|
5
|
+
normalizeLanguageCode,
|
|
6
|
+
} from './getMessageDisplayText'
|
|
7
|
+
|
|
8
|
+
describe('getMessageDisplayText', () => {
|
|
9
|
+
it('returns translated text for the viewer language', () => {
|
|
10
|
+
expect(
|
|
11
|
+
getMessageDisplayText({
|
|
12
|
+
message: {
|
|
13
|
+
text: 'Bonjour',
|
|
14
|
+
i18n: {
|
|
15
|
+
language: 'fr',
|
|
16
|
+
en_text: 'Hello',
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
viewerLanguage: 'en-US',
|
|
20
|
+
})
|
|
21
|
+
).toBe('Hello')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('falls back to the original message text when no translation exists', () => {
|
|
25
|
+
expect(
|
|
26
|
+
getMessageDisplayText({
|
|
27
|
+
message: {
|
|
28
|
+
text: 'Bonjour',
|
|
29
|
+
i18n: {
|
|
30
|
+
language: 'fr',
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
viewerLanguage: 'es',
|
|
34
|
+
})
|
|
35
|
+
).toBe('Bonjour')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('normalizeLanguageCode', () => {
|
|
40
|
+
it('normalizes locale-style language codes to their primary subtag', () => {
|
|
41
|
+
expect(normalizeLanguageCode('fr-FR')).toBe('fr')
|
|
42
|
+
expect(normalizeLanguageCode('en_US')).toBe('en')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { LocalMessage } from 'stream-chat'
|
|
2
|
+
|
|
3
|
+
type MessageWithI18n = Pick<LocalMessage, 'text'> & {
|
|
4
|
+
i18n?: Record<string, string> | null
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function normalizeLanguageCode(language?: string): string | undefined {
|
|
8
|
+
const normalized = language?.trim().toLowerCase().split(/[-_]/)[0]
|
|
9
|
+
return normalized || undefined
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getMessageDisplayText({
|
|
13
|
+
message,
|
|
14
|
+
viewerLanguage,
|
|
15
|
+
}: {
|
|
16
|
+
message?: MessageWithI18n | null
|
|
17
|
+
viewerLanguage?: string
|
|
18
|
+
}): string | undefined {
|
|
19
|
+
const fallbackText = message?.text
|
|
20
|
+
const normalizedLanguage = normalizeLanguageCode(viewerLanguage)
|
|
21
|
+
|
|
22
|
+
if (!normalizedLanguage) {
|
|
23
|
+
return fallbackText
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return message?.i18n?.[`${normalizedLanguage}_text`] ?? fallbackText
|
|
27
|
+
}
|