@linktr.ee/messaging-react 2.0.0 → 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-BHknCeHw.js → Card-BKP9ml9O.js} +2 -2
- package/dist/{Card-BHknCeHw.js.map → Card-BKP9ml9O.js.map} +1 -1
- package/dist/{Card-DT7_ms2p.js → Card-Bk_4lVzP.js} +2 -2
- package/dist/{Card-DT7_ms2p.js.map → Card-Bk_4lVzP.js.map} +1 -1
- package/dist/assets/index.css +1 -1
- package/dist/{index-Brz9orsI.js → index-Bex7eg3v.js} +760 -721
- package/dist/index-Bex7eg3v.js.map +1 -0
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/src/components/ChannelView.test.tsx +11 -0
- package/src/components/ChannelView.tsx +35 -32
- 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/styles.css +7 -0
- package/dist/index-Brz9orsI.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { b as e, c as t, C as i, d as n, e as o, f as g, F as m, g as r, L as M, M as l, h as u, i as h, j as L, k as d, l as C, m as c, n as v, r as A, o as k, u as p, p as F, q as f } from "./index-
|
|
1
|
+
import { b as e, c as t, C as i, d as n, e as o, f as g, F as m, g as r, L as M, M as l, h as u, i as h, j as L, k as d, l as C, m as c, n as v, r as A, o as k, u as p, p as F, q as f } from "./index-Bex7eg3v.js";
|
|
2
2
|
export {
|
|
3
3
|
e as ActionButton,
|
|
4
4
|
t as Avatar,
|
package/package.json
CHANGED
|
@@ -28,6 +28,17 @@ vi.mock('stream-chat-react', () => ({
|
|
|
28
28
|
),
|
|
29
29
|
useMessageContext: () => ({ message: { id: 'message-1', text: 'hello' } }),
|
|
30
30
|
useChannelStateContext: () => ({ channel: activeChannel }),
|
|
31
|
+
useChatContext: () => ({ client: { user: { id: 'visitor-1' } } }),
|
|
32
|
+
useTypingContext: () => ({ typing: {} }),
|
|
33
|
+
useAIState: () => ({ aiState: 'AI_STATE_IDLE' }),
|
|
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
|
+
},
|
|
31
42
|
}))
|
|
32
43
|
|
|
33
44
|
vi.mock('../providers/MessagingProvider', () => ({
|
|
@@ -28,6 +28,7 @@ import { CustomMessage } from './CustomMessage'
|
|
|
28
28
|
import { CustomMessageInput } from './CustomMessageInput'
|
|
29
29
|
import { CustomSystemMessage } from './CustomSystemMessage'
|
|
30
30
|
import CustomTypingIndicator from './CustomTypingIndicator'
|
|
31
|
+
import { DmAgentEnabledContext } from './CustomTypingIndicator/DmAgentContext'
|
|
31
32
|
import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
|
|
32
33
|
import { LoadingState } from './MessagingShell/LoadingState'
|
|
33
34
|
|
|
@@ -495,38 +496,40 @@ export const ChannelView = React.memo<ChannelViewProps>(
|
|
|
495
496
|
className
|
|
496
497
|
)}
|
|
497
498
|
>
|
|
498
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
499
|
+
<DmAgentEnabledContext.Provider value={dmAgentEnabled ?? false}>
|
|
500
|
+
<Channel
|
|
501
|
+
channel={channel}
|
|
502
|
+
MessageSystem={CustomSystemMessage}
|
|
503
|
+
EmptyStateIndicator={CustomChannelEmptyState}
|
|
504
|
+
LoadingIndicator={LoadingState}
|
|
505
|
+
DateSeparator={CustomDateSeparator}
|
|
506
|
+
TypingIndicator={CustomTypingIndicator}
|
|
507
|
+
doSendMessageRequest={doSendMessageRequest}
|
|
508
|
+
{...(sendButton ? { SendButton: sendButton } : {})}
|
|
509
|
+
>
|
|
510
|
+
<ChannelViewInner
|
|
511
|
+
onBack={onBack}
|
|
512
|
+
showBackButton={showBackButton}
|
|
513
|
+
renderMessageInputActions={renderMessageInputActions}
|
|
514
|
+
renderConversationFooter={renderConversationFooter}
|
|
515
|
+
onLeaveConversation={onLeaveConversation}
|
|
516
|
+
onBlockParticipant={onBlockParticipant}
|
|
517
|
+
CustomChannelEmptyState={CustomChannelEmptyState}
|
|
518
|
+
showDeleteConversation={showDeleteConversation}
|
|
519
|
+
onDeleteConversationClick={onDeleteConversationClick}
|
|
520
|
+
onBlockParticipantClick={onBlockParticipantClick}
|
|
521
|
+
onReportParticipantClick={onReportParticipantClick}
|
|
522
|
+
showStarButton={showStarButton}
|
|
523
|
+
dmAgentEnabled={dmAgentEnabled}
|
|
524
|
+
chatbotVotingEnabled={chatbotVotingEnabled}
|
|
525
|
+
renderChannelBanner={renderChannelBanner}
|
|
526
|
+
customProfileContent={customProfileContent}
|
|
527
|
+
customChannelActions={customChannelActions}
|
|
528
|
+
renderMessage={renderMessage}
|
|
529
|
+
viewerLanguage={viewerLanguage}
|
|
530
|
+
/>
|
|
531
|
+
</Channel>
|
|
532
|
+
</DmAgentEnabledContext.Provider>
|
|
530
533
|
</div>
|
|
531
534
|
)
|
|
532
535
|
}
|
|
@@ -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
|
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 {
|