@linktr.ee/messaging-react 1.40.2 → 2.0.1-rc-1778656305

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 (61) hide show
  1. package/dist/Card-CAC3fPjy.js +107 -0
  2. package/dist/Card-CAC3fPjy.js.map +1 -0
  3. package/dist/Card-DLUBUg_w.js +132 -0
  4. package/dist/Card-DLUBUg_w.js.map +1 -0
  5. package/dist/Card-_StSlnYh.js +163 -0
  6. package/dist/Card-_StSlnYh.js.map +1 -0
  7. package/dist/LockedThumbnail-p5RsFOug.js +220 -0
  8. package/dist/LockedThumbnail-p5RsFOug.js.map +1 -0
  9. package/dist/assets/index.css +1 -1
  10. package/dist/index-B1h46F9x.js +3092 -0
  11. package/dist/index-B1h46F9x.js.map +1 -0
  12. package/dist/index.d.ts +109 -30
  13. package/dist/index.js +14 -12
  14. package/package.json +2 -2
  15. package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +2 -14
  16. package/src/components/ChannelInfoDialog/index.tsx +4 -8
  17. package/src/components/ChannelList/ChannelListContext.tsx +2 -0
  18. package/src/components/ChannelList/CustomChannelPreview.tsx +14 -3
  19. package/src/components/ChannelList/index.tsx +9 -1
  20. package/src/components/ChannelView.test.tsx +11 -0
  21. package/src/components/ChannelView.tsx +44 -33
  22. package/src/components/CustomMessage/index.tsx +24 -7
  23. package/src/components/CustomTypingIndicator/CustomTypingIndicator.stories.tsx +57 -17
  24. package/src/components/CustomTypingIndicator/CustomTypingIndicator.test.tsx +187 -0
  25. package/src/components/CustomTypingIndicator/DmAgentContext.ts +3 -0
  26. package/src/components/CustomTypingIndicator/index.tsx +101 -37
  27. package/src/components/LockedAttachment/LockedAttachment.stories.tsx +230 -89
  28. package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
  29. package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
  30. package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
  31. package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
  32. package/src/components/LockedAttachment/components/Received/index.ts +2 -0
  33. package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
  34. package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
  35. package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
  36. package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
  37. package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
  38. package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
  39. package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
  40. package/src/components/LockedAttachment/index.tsx +43 -12
  41. package/src/components/LockedAttachment/types.ts +17 -0
  42. package/src/components/MediaMessage/index.tsx +2 -2
  43. package/src/components/MessagingShell/index.tsx +4 -4
  44. package/src/index.ts +18 -2
  45. package/src/stories/mocks.tsx +2 -9
  46. package/src/styles.css +7 -0
  47. package/src/types.ts +11 -1
  48. package/src/utils/getMessageDisplayText.test.ts +44 -0
  49. package/src/utils/getMessageDisplayText.ts +27 -0
  50. package/dist/Card-A0lkei-S.js +0 -138
  51. package/dist/Card-A0lkei-S.js.map +0 -1
  52. package/dist/Card-DXoAKkv0.js +0 -127
  53. package/dist/Card-DXoAKkv0.js.map +0 -1
  54. package/dist/index-B_PLgcDi.js +0 -2994
  55. package/dist/index-B_PLgcDi.js.map +0 -1
  56. package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
  57. package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
  58. package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
  59. package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
  60. package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
  61. package/src/components/LockedAttachment/components/Visitor/index.ts +0 -2
@@ -30,6 +30,7 @@ import {
30
30
  } from 'stream-chat-react'
31
31
 
32
32
  import { useMessageVote } from '../../hooks/useMessageVote'
33
+ import { getMessageDisplayText } from '../../utils/getMessageDisplayText'
33
34
  import { Avatar } from '../Avatar'
34
35
  import LockedAttachment from '../LockedAttachment'
35
36
  import { isLinkAttachment } from '../MediaMessage'
@@ -45,10 +46,12 @@ import { MessageVoteButtons } from './MessageVoteButtons'
45
46
 
46
47
  type CustomMessageUIComponentProps = MessageUIComponentProps & {
47
48
  chatbotVotingEnabled?: boolean
49
+ viewerLanguage?: string
48
50
  }
49
51
 
50
52
  type CustomMessageWithContextProps = MessageContextValue & {
51
53
  chatbotVotingEnabled?: boolean
54
+ viewerLanguage?: string
52
55
  }
53
56
 
54
57
  const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
@@ -68,11 +71,13 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
68
71
  message,
69
72
  renderText,
70
73
  threadList,
74
+ viewerLanguage,
71
75
  } = props
72
76
 
73
77
  const { client } = useChatContext('CustomMessage')
74
78
  const { channel } = useChannelStateContext('CustomMessage')
75
- const { isUnlocking, onUnlockClick, onFetchSource, onDownloadClick } = useCustomMessage('LockedAttachment')
79
+ const { isUnlocking, onUnlockClick, onFetchSource, onDownloadClick } =
80
+ useCustomMessage('LockedAttachment')
76
81
  const [isBounceDialogOpen, setIsBounceDialogOpen] = useState(false)
77
82
  const reminder = useMessageReminder(message.id)
78
83
  const { selected: voteState, voteUp, voteDown } = useMessageVote(message)
@@ -107,6 +112,12 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
107
112
  const filtered = raw.filter((a) => !('type' in a) || !isLinkAttachment(a))
108
113
  return filtered.length === raw.length ? raw : filtered
109
114
  }, [message])
115
+ const displayMessage = useMemo(() => {
116
+ const displayText = getMessageDisplayText({ message, viewerLanguage })
117
+ return displayText === message.text
118
+ ? message
119
+ : { ...message, text: displayText }
120
+ }, [message, viewerLanguage])
110
121
 
111
122
  if (isDateSeparatorMessage(message)) {
112
123
  return null
@@ -216,21 +227,20 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
216
227
  {isAttachment ? (
217
228
  <div className="str-chat__message-bubble-wrapper">
218
229
  {isMine ? (
219
- <LockedAttachment.Creator
230
+ <LockedAttachment.Sent
220
231
  title={message.metadata?.attachment_title}
221
232
  mimeType={message.metadata?.attachment_mime_type}
222
233
  thumbnailUrl={message.metadata?.attachment_thumbnail}
223
234
  amountText={message.metadata?.amount_text}
224
235
  detail={message.metadata?.attachment_detail}
225
236
  paymentStatus={message.metadata?.payment_status}
226
- isUnlocking={isUnlocking(message.id)}
227
237
  onPreviewClick={() => onUnlockClick?.(message, channel)}
228
238
  onFetchSource={async () =>
229
239
  await onFetchSource?.(message, channel)
230
240
  }
231
241
  />
232
242
  ) : (
233
- <LockedAttachment.Visitor
243
+ <LockedAttachment.Received
234
244
  title={message.metadata?.attachment_title}
235
245
  mimeType={message.metadata?.attachment_mime_type}
236
246
  thumbnailUrl={message.metadata?.attachment_thumbnail}
@@ -247,7 +257,10 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
247
257
  )}
248
258
  {message.text && (
249
259
  <div className="str-chat__message-bubble">
250
- <MessageText message={message} renderText={renderText} />
260
+ <MessageText
261
+ message={displayMessage}
262
+ renderText={renderText}
263
+ />
251
264
  </div>
252
265
  )}
253
266
  </div>
@@ -273,11 +286,14 @@ const CustomMessageWithContext = (props: CustomMessageWithContextProps) => {
273
286
  ) : null}
274
287
  {isAIGenerated ? (
275
288
  <StreamedMessageText
276
- message={message}
289
+ message={displayMessage}
277
290
  renderText={renderText}
278
291
  />
279
292
  ) : (
280
- <MessageText message={message} renderText={renderText} />
293
+ <MessageText
294
+ message={displayMessage}
295
+ renderText={renderText}
296
+ />
281
297
  )}
282
298
  <MessageErrorIcon />
283
299
  </div>
@@ -317,6 +333,7 @@ const MemoizedCustomMessage = React.memo(
317
333
  CustomMessageWithContext,
318
334
  (prev, next) => {
319
335
  if (prev.chatbotVotingEnabled !== next.chatbotVotingEnabled) return false
336
+ if (prev.viewerLanguage !== next.viewerLanguage) return false
320
337
  return areMessageUIPropsEqual(prev, next)
321
338
  }
322
339
  ) as typeof CustomMessageWithContext
@@ -2,16 +2,21 @@ import type { Meta, StoryFn } from '@storybook/react'
2
2
  import React from 'react'
3
3
  import type { Event } from 'stream-chat'
4
4
  import {
5
+ AIStates,
5
6
  ChannelStateProvider,
6
7
  ChatProvider,
7
8
  TypingProvider,
8
9
  } from 'stream-chat-react'
9
10
 
11
+ import { DmAgentEnabledContext } from './DmAgentContext'
12
+
10
13
  import CustomTypingIndicator from '.'
11
14
 
12
15
  type StoryProps = {
13
16
  typingEventsEnabled?: boolean
14
17
  typing?: Record<string, Event>
18
+ aiState?: string
19
+ dmAgentEnabled?: boolean
15
20
  }
16
21
 
17
22
  const currentUser = {
@@ -33,10 +38,38 @@ const defaultTyping: Record<string, Event> = {
33
38
  } as Event,
34
39
  }
35
40
 
41
+ type ListenerMap = Record<string, (event: { ai_state: string; cid: string }) => void>
42
+
43
+ const createMockChannel = ({ aiState }: { aiState?: string }) => {
44
+ const listeners: ListenerMap = {}
45
+ const channel = {
46
+ cid: 'messaging:test',
47
+ state: {
48
+ members: {
49
+ [currentUser.id]: { user: currentUser },
50
+ [typingUser.id]: { user: typingUser },
51
+ },
52
+ },
53
+ on(eventType: string, handler: ListenerMap[string]) {
54
+ listeners[eventType] = handler
55
+ // Fire the requested AI state synchronously so the hook picks it up.
56
+ if (eventType === 'ai_indicator.update' && aiState) {
57
+ handler({ ai_state: aiState, cid: 'messaging:test' })
58
+ }
59
+ return { unsubscribe: () => {} }
60
+ },
61
+ }
62
+ return channel
63
+ }
64
+
36
65
  const StoryWrapper: React.FC<StoryProps> = ({
37
66
  typingEventsEnabled = true,
38
- typing = defaultTyping,
67
+ typing = {},
68
+ aiState,
69
+ dmAgentEnabled = true,
39
70
  }) => {
71
+ const channel = createMockChannel({ aiState })
72
+
40
73
  const chatContextValue = {
41
74
  client: {
42
75
  user: currentUser,
@@ -57,18 +90,7 @@ const StoryWrapper: React.FC<StoryProps> = ({
57
90
  }
58
91
 
59
92
  const channelStateValue = {
60
- channel: {
61
- state: {
62
- members: {
63
- [currentUser.id]: {
64
- user: currentUser,
65
- },
66
- [typingUser.id]: {
67
- user: typingUser,
68
- },
69
- },
70
- },
71
- },
93
+ channel,
72
94
  channelCapabilities: {},
73
95
  channelConfig: {
74
96
  typing_events: typingEventsEnabled,
@@ -85,9 +107,11 @@ const StoryWrapper: React.FC<StoryProps> = ({
85
107
  <ChatProvider value={chatContextValue as never}>
86
108
  <ChannelStateProvider value={channelStateValue as never}>
87
109
  <TypingProvider value={{ typing }}>
88
- <div className="relative h-20 w-[200px] bg-[#f4f4f4] p-3">
89
- <CustomTypingIndicator />
90
- </div>
110
+ <DmAgentEnabledContext.Provider value={dmAgentEnabled}>
111
+ <div className="relative h-20 w-[200px] bg-[#f4f4f4] p-3">
112
+ <CustomTypingIndicator />
113
+ </div>
114
+ </DmAgentEnabledContext.Provider>
91
115
  </TypingProvider>
92
116
  </ChannelStateProvider>
93
117
  </ChatProvider>
@@ -104,7 +128,9 @@ const meta: Meta<StoryProps> = {
104
128
  export default meta
105
129
 
106
130
  export const Default: StoryFn<StoryProps> = (args) => <StoryWrapper {...args} />
107
- Default.args = {}
131
+ Default.args = {
132
+ typing: defaultTyping,
133
+ }
108
134
 
109
135
  export const HiddenWhenNoTyping: StoryFn<StoryProps> = (args) => (
110
136
  <StoryWrapper {...args} />
@@ -112,3 +138,17 @@ export const HiddenWhenNoTyping: StoryFn<StoryProps> = (args) => (
112
138
  HiddenWhenNoTyping.args = {
113
139
  typing: {},
114
140
  }
141
+
142
+ export const AiAgentThinking: StoryFn<StoryProps> = (args) => (
143
+ <StoryWrapper {...args} />
144
+ )
145
+ AiAgentThinking.args = {
146
+ aiState: AIStates.Thinking,
147
+ }
148
+
149
+ export const AiAgentGenerating: StoryFn<StoryProps> = (args) => (
150
+ <StoryWrapper {...args} />
151
+ )
152
+ AiAgentGenerating.args = {
153
+ aiState: AIStates.Generating,
154
+ }
@@ -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