@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.
Files changed (31) hide show
  1. package/dist/{Card-A0lkei-S.js → Card-BKP9ml9O.js} +2 -2
  2. package/dist/{Card-A0lkei-S.js.map → Card-BKP9ml9O.js.map} +1 -1
  3. package/dist/{Card-DXoAKkv0.js → Card-Bk_4lVzP.js} +2 -2
  4. package/dist/{Card-DXoAKkv0.js.map → Card-Bk_4lVzP.js.map} +1 -1
  5. package/dist/assets/index.css +1 -1
  6. package/dist/index-Bex7eg3v.js +3092 -0
  7. package/dist/index-Bex7eg3v.js.map +1 -0
  8. package/dist/index.d.ts +22 -2
  9. package/dist/index.js +12 -10
  10. package/package.json +2 -2
  11. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +2 -14
  12. package/src/components/ChannelInfoDialog/index.tsx +4 -8
  13. package/src/components/ChannelList/ChannelListContext.tsx +2 -0
  14. package/src/components/ChannelList/CustomChannelPreview.tsx +14 -3
  15. package/src/components/ChannelList/index.tsx +9 -1
  16. package/src/components/ChannelView.test.tsx +11 -0
  17. package/src/components/ChannelView.tsx +44 -33
  18. package/src/components/CustomMessage/index.tsx +22 -4
  19. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  20. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  21. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  22. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  23. package/src/components/MessagingShell/index.tsx +4 -4
  24. package/src/index.ts +13 -2
  25. package/src/stories/mocks.tsx +2 -9
  26. package/src/styles.css +7 -0
  27. package/src/types.ts +11 -1
  28. package/src/utils/getMessageDisplayText.test.ts +44 -0
  29. package/src/utils/getMessageDisplayText.ts +27 -0
  30. package/dist/index-B_PLgcDi.js +0 -2994
  31. 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
+ })
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react'
2
+
3
+ export const DmAgentEnabledContext = createContext<boolean>(false)
@@ -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
- <div
69
- className="str-chat__typing-indicator !items-end !bg-transparent"
70
- data-testid="typing-indicator"
71
- style={{ insetInlineStart: 0, insetInlineEnd: 'auto' }}
72
- >
73
- <div className="shrink-0" aria-hidden="true">
74
- <Avatar
75
- id={avatarId}
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 { MediaMessageProps, MediaMessageResolved } from './components/MediaMessage'
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 { CreatorCardProps, VisitorCardProps, LockedAttachmentContextValue } from './components/LockedAttachment'
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'
@@ -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
- (p) =>
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
+ }