@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.
- package/dist/Card-CAC3fPjy.js +107 -0
- package/dist/Card-CAC3fPjy.js.map +1 -0
- package/dist/Card-DLUBUg_w.js +132 -0
- package/dist/Card-DLUBUg_w.js.map +1 -0
- package/dist/Card-_StSlnYh.js +163 -0
- package/dist/Card-_StSlnYh.js.map +1 -0
- package/dist/LockedThumbnail-p5RsFOug.js +220 -0
- package/dist/LockedThumbnail-p5RsFOug.js.map +1 -0
- package/dist/assets/index.css +1 -1
- package/dist/index-B1h46F9x.js +3092 -0
- package/dist/index-B1h46F9x.js.map +1 -0
- package/dist/index.d.ts +109 -30
- package/dist/index.js +14 -12
- 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 +24 -7
- 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/LockedAttachment/LockedAttachment.stories.tsx +230 -89
- package/src/components/LockedAttachment/components/Composer/Card.tsx +221 -0
- package/src/components/LockedAttachment/components/Composer/index.ts +2 -0
- package/src/components/LockedAttachment/components/Received/Card.tsx +191 -0
- package/src/components/LockedAttachment/components/Received/CardActions.tsx +91 -0
- package/src/components/LockedAttachment/components/Received/index.ts +2 -0
- package/src/components/LockedAttachment/components/Sent/Card.tsx +177 -0
- package/src/components/LockedAttachment/components/Sent/index.ts +2 -0
- package/src/components/LockedAttachment/components/_shared/CardBody.tsx +94 -0
- package/src/components/LockedAttachment/components/_shared/GalleryThumbnail.tsx +178 -0
- package/src/components/LockedAttachment/components/_shared/LockBadge.tsx +39 -0
- package/src/components/LockedAttachment/components/_shared/LockedCardShell.tsx +36 -0
- package/src/components/LockedAttachment/components/_shared/LockedThumbnail.tsx +128 -0
- package/src/components/LockedAttachment/index.tsx +43 -12
- package/src/components/LockedAttachment/types.ts +17 -0
- package/src/components/MediaMessage/index.tsx +2 -2
- package/src/components/MessagingShell/index.tsx +4 -4
- package/src/index.ts +18 -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/Card-A0lkei-S.js +0 -138
- package/dist/Card-A0lkei-S.js.map +0 -1
- package/dist/Card-DXoAKkv0.js +0 -127
- package/dist/Card-DXoAKkv0.js.map +0 -1
- package/dist/index-B_PLgcDi.js +0 -2994
- package/dist/index-B_PLgcDi.js.map +0 -1
- package/src/components/LockedAttachment/components/Creator/Card.tsx +0 -210
- package/src/components/LockedAttachment/components/Creator/index.tsx +0 -2
- package/src/components/LockedAttachment/components/Visitor/Card.tsx +0 -155
- package/src/components/LockedAttachment/components/Visitor/CardActions.tsx +0 -62
- package/src/components/LockedAttachment/components/Visitor/LockBadge.tsx +0 -12
- 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 } =
|
|
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.
|
|
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.
|
|
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
|
|
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={
|
|
289
|
+
message={displayMessage}
|
|
277
290
|
renderText={renderText}
|
|
278
291
|
/>
|
|
279
292
|
) : (
|
|
280
|
-
<MessageText
|
|
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 =
|
|
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
|
-
<
|
|
89
|
-
<
|
|
90
|
-
|
|
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
|
+
})
|
|
@@ -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
|