@linktr.ee/messaging-react 1.0.0 → 1.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/package.json +3 -2
- package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
- package/src/components/ActionButton/ActionButton.test.tsx +112 -0
- package/src/components/ActionButton/index.tsx +33 -0
- package/src/components/Avatar/Avatar.stories.tsx +144 -0
- package/src/components/Avatar/avatarColors.ts +36 -0
- package/src/components/Avatar/index.tsx +64 -0
- package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
- package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
- package/src/components/ChannelList/CustomChannelPreview.tsx +114 -0
- package/src/components/ChannelList/index.tsx +129 -0
- package/src/components/ChannelView.tsx +422 -0
- package/src/components/CloseButton/index.tsx +16 -0
- package/src/components/IconButton/IconButton.stories.tsx +40 -0
- package/src/components/IconButton/index.tsx +32 -0
- package/src/components/Loading/Loading.stories.tsx +24 -0
- package/src/components/Loading/index.tsx +50 -0
- package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
- package/src/components/MessagingShell/EmptyState.tsx +55 -0
- package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
- package/src/components/MessagingShell/ErrorState.tsx +33 -0
- package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
- package/src/components/MessagingShell/LoadingState.tsx +15 -0
- package/src/components/MessagingShell/index.tsx +298 -0
- package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
- package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
- package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
- package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
- package/src/components/ParticipantPicker/index.tsx +234 -0
- package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
- package/src/components/SearchInput/SearchInput.test.tsx +108 -0
- package/src/components/SearchInput/index.tsx +50 -0
- package/src/hooks/useMessaging.ts +9 -0
- package/src/hooks/useParticipants.ts +92 -0
- package/src/index.ts +26 -0
- package/src/providers/MessagingProvider.tsx +282 -0
- package/src/stories/mocks.tsx +157 -0
- package/src/test/setup.ts +30 -0
- package/src/test/utils.tsx +23 -0
- package/src/types.ts +113 -0
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from '@storybook/react'
|
|
2
|
+
import { ErrorState } from './ErrorState'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
type ComponentProps = React.ComponentProps<typeof ErrorState>
|
|
6
|
+
|
|
7
|
+
const meta: Meta<ComponentProps> = {
|
|
8
|
+
title: 'States/ErrorState',
|
|
9
|
+
component: ErrorState,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
|
|
17
|
+
const Template: StoryFn<ComponentProps> = (args) => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="h-screen w-full bg-white">
|
|
20
|
+
<ErrorState {...args} />
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const ConnectionError: StoryFn<ComponentProps> = Template.bind({})
|
|
26
|
+
ConnectionError.args = {
|
|
27
|
+
error: 'Unable to connect to messaging service. Please check your connection and try again.',
|
|
28
|
+
onRetry: () => console.log('Retry clicked'),
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const NoRetry: StoryFn<ComponentProps> = Template.bind({})
|
|
32
|
+
NoRetry.args = {
|
|
33
|
+
error: 'Failed to load messages.',
|
|
34
|
+
onRetry: undefined,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const CustomError: StoryFn<ComponentProps> = Template.bind({})
|
|
38
|
+
CustomError.args = {
|
|
39
|
+
error: 'Your session has expired. Please refresh the page to continue.',
|
|
40
|
+
onRetry: () => console.log('Retry clicked'),
|
|
41
|
+
}
|
|
42
|
+
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error state component
|
|
5
|
+
*/
|
|
6
|
+
export const ErrorState: React.FC<{ error: string; onRetry?: () => void }> = ({ error, onRetry }) => (
|
|
7
|
+
<div className="flex items-center justify-center h-full p-8">
|
|
8
|
+
<div className="text-center max-w-md">
|
|
9
|
+
<div className="w-24 h-24 bg-danger-alt rounded-full flex items-center justify-center mx-auto mb-6">
|
|
10
|
+
<span className="text-4xl">⚠️</span>
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
<h2 className="text-xl font-semibold text-charcoal mb-3">
|
|
14
|
+
Connection Error
|
|
15
|
+
</h2>
|
|
16
|
+
|
|
17
|
+
<p className="text-stone text-sm mb-6">
|
|
18
|
+
{error}
|
|
19
|
+
</p>
|
|
20
|
+
|
|
21
|
+
{onRetry && (
|
|
22
|
+
<button
|
|
23
|
+
type="button"
|
|
24
|
+
onClick={onRetry}
|
|
25
|
+
className="inline-flex items-center px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary-alt focus:outline-none focus:ring-2 focus:ring-primary transition-colors"
|
|
26
|
+
>
|
|
27
|
+
Try Again
|
|
28
|
+
</button>
|
|
29
|
+
)}
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
);
|
|
33
|
+
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from '@storybook/react'
|
|
2
|
+
import { LoadingState } from './LoadingState'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
type ComponentProps = React.ComponentProps<typeof LoadingState>
|
|
6
|
+
|
|
7
|
+
const meta: Meta<ComponentProps> = {
|
|
8
|
+
title: 'States/LoadingState',
|
|
9
|
+
component: LoadingState,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'fullscreen',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
|
|
17
|
+
const Template: StoryFn<ComponentProps> = () => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="h-screen w-full bg-white">
|
|
20
|
+
<LoadingState />
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Default: StoryFn<ComponentProps> = Template.bind({})
|
|
26
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import Loading from '../Loading';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Loading state component
|
|
6
|
+
*/
|
|
7
|
+
export const LoadingState = () => (
|
|
8
|
+
<div className="flex items-center justify-center h-full">
|
|
9
|
+
<div className="flex items-center">
|
|
10
|
+
<Loading className='w-6 h-6' />
|
|
11
|
+
<span className="text-sm text-stone">Loading messages</span>
|
|
12
|
+
</div>
|
|
13
|
+
</div>
|
|
14
|
+
);
|
|
15
|
+
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
import React, { useState, useCallback, useRef, useEffect } from 'react';
|
|
2
|
+
import classNames from 'classnames';
|
|
3
|
+
import type { Channel } from 'stream-chat';
|
|
4
|
+
import { ChannelList } from '../ChannelList';
|
|
5
|
+
import { ChannelView } from '../ChannelView';
|
|
6
|
+
import { ParticipantPicker } from '../ParticipantPicker';
|
|
7
|
+
import { useMessaging } from '../../hooks/useMessaging';
|
|
8
|
+
import type { MessagingShellProps, Participant } from '../../types';
|
|
9
|
+
import { EmptyState } from './EmptyState';
|
|
10
|
+
import { LoadingState } from './LoadingState';
|
|
11
|
+
import { ErrorState } from './ErrorState';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Main messaging interface component that combines channel list and channel view
|
|
15
|
+
*/
|
|
16
|
+
export const MessagingShell: React.FC<MessagingShellProps> = ({
|
|
17
|
+
capabilities = {},
|
|
18
|
+
customization = {},
|
|
19
|
+
className,
|
|
20
|
+
renderMessageInputActions,
|
|
21
|
+
onChannelSelect,
|
|
22
|
+
onParticipantSelect,
|
|
23
|
+
}) => {
|
|
24
|
+
const {
|
|
25
|
+
service,
|
|
26
|
+
client,
|
|
27
|
+
isConnected,
|
|
28
|
+
isLoading,
|
|
29
|
+
error,
|
|
30
|
+
refreshConnection,
|
|
31
|
+
debug,
|
|
32
|
+
} = useMessaging();
|
|
33
|
+
|
|
34
|
+
const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null);
|
|
35
|
+
const [hasChannels, setHasChannels] = useState(false);
|
|
36
|
+
const [showParticipantPicker, setShowParticipantPicker] = useState(false);
|
|
37
|
+
const [existingParticipantIds, setExistingParticipantIds] = useState<Set<string>>(new Set());
|
|
38
|
+
const [pickerKey, setPickerKey] = useState(0); // Key to force remount of ParticipantPicker
|
|
39
|
+
|
|
40
|
+
const participantPickerRef = useRef<HTMLDialogElement>(null);
|
|
41
|
+
|
|
42
|
+
const {
|
|
43
|
+
showStartConversation = false,
|
|
44
|
+
participantSource,
|
|
45
|
+
participantLabel = 'participants',
|
|
46
|
+
} = capabilities;
|
|
47
|
+
|
|
48
|
+
// Track if we've already synced channels to prevent repeated API calls
|
|
49
|
+
const syncedRef = useRef<string | null>(null);
|
|
50
|
+
|
|
51
|
+
// Function to sync channels (extracted for reuse)
|
|
52
|
+
const syncChannels = useCallback(async () => {
|
|
53
|
+
if (!client || !isConnected) return;
|
|
54
|
+
|
|
55
|
+
const userId = client.userID;
|
|
56
|
+
if (!userId) return;
|
|
57
|
+
|
|
58
|
+
try {
|
|
59
|
+
if (debug) {
|
|
60
|
+
console.log('[MessagingShell] Syncing channels for user:', userId);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const channels = await client.queryChannels(
|
|
64
|
+
{
|
|
65
|
+
type: 'messaging',
|
|
66
|
+
members: { $in: [userId] },
|
|
67
|
+
},
|
|
68
|
+
{},
|
|
69
|
+
{ limit: 100 }
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const memberIds = new Set<string>();
|
|
73
|
+
channels.forEach((channel: Channel) => {
|
|
74
|
+
const members = channel.state.members || {};
|
|
75
|
+
Object.values(members).forEach((member: any) => {
|
|
76
|
+
const memberId = member.user?.id;
|
|
77
|
+
if (memberId && memberId !== userId) {
|
|
78
|
+
memberIds.add(memberId);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
setExistingParticipantIds(memberIds);
|
|
84
|
+
setHasChannels(channels.length > 0);
|
|
85
|
+
syncedRef.current = userId; // Mark as synced for this user
|
|
86
|
+
|
|
87
|
+
if (debug) {
|
|
88
|
+
console.log('[MessagingShell] Channels synced successfully:', {
|
|
89
|
+
channelCount: channels.length,
|
|
90
|
+
memberCount: memberIds.size
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
} catch (error) {
|
|
94
|
+
console.error('[MessagingShell] Failed to sync channels:', error);
|
|
95
|
+
// Don't mark as synced on error, allow retry
|
|
96
|
+
}
|
|
97
|
+
}, [client, isConnected, debug]);
|
|
98
|
+
|
|
99
|
+
// Sync existing channels to track which participants we can already message
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
if (!client || !isConnected) return;
|
|
102
|
+
|
|
103
|
+
const userId = client.userID;
|
|
104
|
+
if (!userId) return;
|
|
105
|
+
|
|
106
|
+
// Prevent repeated sync for the same user
|
|
107
|
+
if (syncedRef.current === userId) return;
|
|
108
|
+
|
|
109
|
+
syncChannels();
|
|
110
|
+
}, [client, isConnected, syncChannels]);
|
|
111
|
+
|
|
112
|
+
const handleChannelSelect = useCallback((channel: Channel) => {
|
|
113
|
+
setSelectedChannel(channel);
|
|
114
|
+
onChannelSelect?.(channel);
|
|
115
|
+
}, [onChannelSelect]);
|
|
116
|
+
|
|
117
|
+
const handleBackToChannelList = useCallback(() => {
|
|
118
|
+
setSelectedChannel(null);
|
|
119
|
+
}, []);
|
|
120
|
+
|
|
121
|
+
const handleStartConversation = useCallback(() => {
|
|
122
|
+
if (participantSource) {
|
|
123
|
+
setPickerKey(prev => prev + 1); // Increment key to force remount
|
|
124
|
+
setShowParticipantPicker(true);
|
|
125
|
+
participantPickerRef.current?.showModal();
|
|
126
|
+
}
|
|
127
|
+
}, [participantSource]);
|
|
128
|
+
|
|
129
|
+
const handleSelectParticipant = useCallback(async (participant: Participant) => {
|
|
130
|
+
if (!service) return;
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
if (debug) {
|
|
134
|
+
console.log('[MessagingShell] Starting conversation with:', participant.id);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const channel = await service.startChannelWithFollower({
|
|
138
|
+
id: participant.id,
|
|
139
|
+
name: participant.name,
|
|
140
|
+
email: participant.email,
|
|
141
|
+
phone: participant.phone,
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Show the channel
|
|
145
|
+
try {
|
|
146
|
+
await channel.show();
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.warn('[MessagingShell] Failed to unhide channel:', error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
setSelectedChannel(channel);
|
|
152
|
+
setShowParticipantPicker(false);
|
|
153
|
+
participantPickerRef.current?.close();
|
|
154
|
+
|
|
155
|
+
onParticipantSelect?.(participant);
|
|
156
|
+
} catch (error) {
|
|
157
|
+
console.error('[MessagingShell] Failed to start conversation:', error);
|
|
158
|
+
}
|
|
159
|
+
}, [service, onParticipantSelect, debug]);
|
|
160
|
+
|
|
161
|
+
const handleCloseParticipantPicker = useCallback(() => {
|
|
162
|
+
setShowParticipantPicker(false);
|
|
163
|
+
participantPickerRef.current?.close();
|
|
164
|
+
}, []);
|
|
165
|
+
|
|
166
|
+
const handleLeaveConversation = useCallback(async (channel: Channel) => {
|
|
167
|
+
if (debug) {
|
|
168
|
+
console.log('[MessagingShell] Leaving conversation:', channel.id);
|
|
169
|
+
}
|
|
170
|
+
setSelectedChannel(null);
|
|
171
|
+
|
|
172
|
+
// Force re-sync to update the existing participants list
|
|
173
|
+
syncedRef.current = null;
|
|
174
|
+
await syncChannels();
|
|
175
|
+
}, [syncChannels, debug]);
|
|
176
|
+
|
|
177
|
+
const handleBlockParticipant = useCallback(async (participantId?: string) => {
|
|
178
|
+
if (debug) {
|
|
179
|
+
console.log('[MessagingShell] Blocking participant:', participantId);
|
|
180
|
+
}
|
|
181
|
+
setSelectedChannel(null);
|
|
182
|
+
|
|
183
|
+
// Force re-sync to update the existing participants list
|
|
184
|
+
syncedRef.current = null;
|
|
185
|
+
await syncChannels();
|
|
186
|
+
}, [syncChannels, debug]);
|
|
187
|
+
|
|
188
|
+
const isChannelSelected = Boolean(selectedChannel);
|
|
189
|
+
|
|
190
|
+
// Show loading state
|
|
191
|
+
if (isLoading) {
|
|
192
|
+
return (
|
|
193
|
+
<div className={classNames('h-full', className)}>
|
|
194
|
+
<LoadingState />
|
|
195
|
+
</div>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Show error state
|
|
200
|
+
if (error) {
|
|
201
|
+
return (
|
|
202
|
+
<div className={classNames('h-full', className)}>
|
|
203
|
+
<ErrorState error={error} onRetry={refreshConnection} />
|
|
204
|
+
</div>
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Show not connected state
|
|
209
|
+
if (!isConnected || !client) {
|
|
210
|
+
return (
|
|
211
|
+
<div className={classNames('h-full', className)}>
|
|
212
|
+
<ErrorState
|
|
213
|
+
error="Not connected to messaging service"
|
|
214
|
+
onRetry={refreshConnection}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return (
|
|
221
|
+
<div className={classNames('h-full bg-white overflow-hidden', className)}>
|
|
222
|
+
<div className="flex h-full min-h-0">
|
|
223
|
+
{/* Channel List Sidebar */}
|
|
224
|
+
<div
|
|
225
|
+
className={classNames(
|
|
226
|
+
'min-h-0 min-w-0 bg-white lg:bg-chalk lg:flex lg:flex-col lg:border-r lg:border-sand',
|
|
227
|
+
{
|
|
228
|
+
'hidden lg:flex lg:w-80 lg:min-w-[280px] lg:max-w-[360px]': isChannelSelected,
|
|
229
|
+
'flex flex-col w-full lg:flex-1 lg:max-w-2xl': !isChannelSelected,
|
|
230
|
+
}
|
|
231
|
+
)}
|
|
232
|
+
>
|
|
233
|
+
<ChannelList
|
|
234
|
+
onChannelSelect={handleChannelSelect}
|
|
235
|
+
selectedChannel={selectedChannel || undefined}
|
|
236
|
+
showStartConversation={showStartConversation && Boolean(participantSource)}
|
|
237
|
+
onStartConversation={handleStartConversation}
|
|
238
|
+
participantLabel={participantLabel}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
|
|
242
|
+
{/* Channel View */}
|
|
243
|
+
<div
|
|
244
|
+
className={classNames('flex-1 flex-col min-w-0 min-h-0', {
|
|
245
|
+
'hidden lg:flex': !isChannelSelected,
|
|
246
|
+
'flex': isChannelSelected,
|
|
247
|
+
})}
|
|
248
|
+
>
|
|
249
|
+
{selectedChannel ? (
|
|
250
|
+
<div className="flex-1 min-h-0 flex flex-col">
|
|
251
|
+
<ChannelView
|
|
252
|
+
channel={selectedChannel}
|
|
253
|
+
key={selectedChannel.id}
|
|
254
|
+
onBack={handleBackToChannelList}
|
|
255
|
+
showBackButton
|
|
256
|
+
renderMessageInputActions={renderMessageInputActions}
|
|
257
|
+
onLeaveConversation={handleLeaveConversation}
|
|
258
|
+
onBlockParticipant={handleBlockParticipant}
|
|
259
|
+
/>
|
|
260
|
+
</div>
|
|
261
|
+
) : (
|
|
262
|
+
<EmptyState
|
|
263
|
+
hasChannels={hasChannels}
|
|
264
|
+
onStartConversation={showStartConversation ? handleStartConversation : undefined}
|
|
265
|
+
participantLabel={participantLabel}
|
|
266
|
+
/>
|
|
267
|
+
)}
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Participant Picker Dialog */}
|
|
272
|
+
{participantSource && (
|
|
273
|
+
<dialog
|
|
274
|
+
ref={participantPickerRef}
|
|
275
|
+
className="mes-dialog"
|
|
276
|
+
onClick={(e) => {
|
|
277
|
+
if (e.target === participantPickerRef.current) {
|
|
278
|
+
handleCloseParticipantPicker();
|
|
279
|
+
}
|
|
280
|
+
}}
|
|
281
|
+
onClose={handleCloseParticipantPicker}
|
|
282
|
+
>
|
|
283
|
+
<div className="h-full w-full bg-white shadow-max-elevation-light">
|
|
284
|
+
<ParticipantPicker
|
|
285
|
+
key={pickerKey}
|
|
286
|
+
participantSource={participantSource}
|
|
287
|
+
onSelectParticipant={handleSelectParticipant}
|
|
288
|
+
onClose={handleCloseParticipantPicker}
|
|
289
|
+
existingParticipantIds={existingParticipantIds}
|
|
290
|
+
participantLabel={participantLabel}
|
|
291
|
+
searchPlaceholder={`Search ${participantLabel}...`}
|
|
292
|
+
/>
|
|
293
|
+
</div>
|
|
294
|
+
</dialog>
|
|
295
|
+
)}
|
|
296
|
+
</div>
|
|
297
|
+
);
|
|
298
|
+
};
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react';
|
|
2
|
+
import { ParticipantItem } from './ParticipantItem';
|
|
3
|
+
import type { Participant } from '../../types';
|
|
4
|
+
import React from 'react';
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof ParticipantItem> = {
|
|
7
|
+
title: 'ParticipantItem',
|
|
8
|
+
component: ParticipantItem,
|
|
9
|
+
parameters: {
|
|
10
|
+
// layout: 'centered',
|
|
11
|
+
},
|
|
12
|
+
tags: ['autodocs'],
|
|
13
|
+
decorators: [
|
|
14
|
+
(Story) => (
|
|
15
|
+
<ul className="w-96 border border-sand rounded-lg overflow-hidden">
|
|
16
|
+
<Story />
|
|
17
|
+
</ul>
|
|
18
|
+
),
|
|
19
|
+
],
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export default meta;
|
|
23
|
+
type Story = StoryObj<typeof ParticipantItem>;
|
|
24
|
+
|
|
25
|
+
const mockParticipant: Participant = {
|
|
26
|
+
id: 'user-123',
|
|
27
|
+
name: 'Sarah Johnson',
|
|
28
|
+
email: 'sarah.johnson@example.com',
|
|
29
|
+
image: 'https://picsum.photos/id/237/200/200',
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const mockHandleSelect = (participant: Participant) => {
|
|
33
|
+
console.log('Selected participant:', participant);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const mockHandleKeyDown = (event: React.KeyboardEvent, participant: Participant) => {
|
|
37
|
+
console.log('Key pressed:', event.key, 'on participant:', participant);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const Default: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
participant: mockParticipant,
|
|
43
|
+
handleSelectParticipant: mockHandleSelect,
|
|
44
|
+
handleKeyDown: mockHandleKeyDown,
|
|
45
|
+
displayName: 'Sarah Johnson',
|
|
46
|
+
displaySecondary: 'sarah.johnson@example.com',
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export const WithPhone: Story = {
|
|
51
|
+
args: {
|
|
52
|
+
participant: {
|
|
53
|
+
...mockParticipant,
|
|
54
|
+
phone: '+1 (555) 123-4567',
|
|
55
|
+
},
|
|
56
|
+
handleSelectParticipant: mockHandleSelect,
|
|
57
|
+
handleKeyDown: mockHandleKeyDown,
|
|
58
|
+
displayName: 'Sarah Johnson',
|
|
59
|
+
displaySecondary: '+1 (555) 123-4567',
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export const NoSecondaryInfo: Story = {
|
|
64
|
+
args: {
|
|
65
|
+
participant: {
|
|
66
|
+
id: 'user-456',
|
|
67
|
+
name: 'John Doe',
|
|
68
|
+
},
|
|
69
|
+
handleSelectParticipant: mockHandleSelect,
|
|
70
|
+
handleKeyDown: mockHandleKeyDown,
|
|
71
|
+
displayName: 'John Doe',
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export const Loading: Story = {
|
|
76
|
+
args: {
|
|
77
|
+
participant: mockParticipant,
|
|
78
|
+
handleSelectParticipant: mockHandleSelect,
|
|
79
|
+
handleKeyDown: mockHandleKeyDown,
|
|
80
|
+
displayName: 'Sarah Johnson',
|
|
81
|
+
displaySecondary: 'sarah.johnson@example.com',
|
|
82
|
+
startingChatWithId: 'user-123',
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
export const LongName: Story = {
|
|
87
|
+
args: {
|
|
88
|
+
participant: {
|
|
89
|
+
id: 'user-789',
|
|
90
|
+
name: 'Alexander Christopher Wellington-Montgomery III',
|
|
91
|
+
email: 'alexander.christopher.wellington@example.com',
|
|
92
|
+
},
|
|
93
|
+
handleSelectParticipant: mockHandleSelect,
|
|
94
|
+
handleKeyDown: mockHandleKeyDown,
|
|
95
|
+
displayName: 'Alexander Christopher Wellington-Montgomery III',
|
|
96
|
+
displaySecondary: 'alexander.christopher.wellington@example.com',
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
export const DifferentColors: Story = {
|
|
101
|
+
render: () => (
|
|
102
|
+
<div className="space-y-0 w-96">
|
|
103
|
+
<ParticipantItem
|
|
104
|
+
participant={{ id: '1', name: 'Alice Anderson' }}
|
|
105
|
+
handleSelectParticipant={mockHandleSelect}
|
|
106
|
+
handleKeyDown={mockHandleKeyDown}
|
|
107
|
+
displayName="Alice Anderson"
|
|
108
|
+
displaySecondary="alice@example.com"
|
|
109
|
+
/>
|
|
110
|
+
<ParticipantItem
|
|
111
|
+
participant={{ id: '2', name: 'Bob Brown' }}
|
|
112
|
+
handleSelectParticipant={mockHandleSelect}
|
|
113
|
+
handleKeyDown={mockHandleKeyDown}
|
|
114
|
+
displayName="Bob Brown"
|
|
115
|
+
displaySecondary="bob@example.com"
|
|
116
|
+
/>
|
|
117
|
+
<ParticipantItem
|
|
118
|
+
participant={{ id: '3', name: 'Charlie Chen' }}
|
|
119
|
+
handleSelectParticipant={mockHandleSelect}
|
|
120
|
+
handleKeyDown={mockHandleKeyDown}
|
|
121
|
+
displayName="Charlie Chen"
|
|
122
|
+
displaySecondary="charlie@example.com"
|
|
123
|
+
/>
|
|
124
|
+
<ParticipantItem
|
|
125
|
+
participant={{ id: '4', name: 'Diana Davis' }}
|
|
126
|
+
handleSelectParticipant={mockHandleSelect}
|
|
127
|
+
handleKeyDown={mockHandleKeyDown}
|
|
128
|
+
displayName="Diana Davis"
|
|
129
|
+
displaySecondary="diana@example.com"
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
export const MixedAvatars: Story = {
|
|
136
|
+
render: () => (
|
|
137
|
+
<div className="space-y-0 w-96">
|
|
138
|
+
<ParticipantItem
|
|
139
|
+
participant={{
|
|
140
|
+
id: '1',
|
|
141
|
+
name: 'Emma Wilson',
|
|
142
|
+
image: 'https://picsum.photos/id/64/200/200'
|
|
143
|
+
}}
|
|
144
|
+
handleSelectParticipant={mockHandleSelect}
|
|
145
|
+
handleKeyDown={mockHandleKeyDown}
|
|
146
|
+
displayName="Emma Wilson"
|
|
147
|
+
displaySecondary="emma@example.com"
|
|
148
|
+
/>
|
|
149
|
+
<ParticipantItem
|
|
150
|
+
participant={{ id: '2', name: 'Frank Miller' }}
|
|
151
|
+
handleSelectParticipant={mockHandleSelect}
|
|
152
|
+
handleKeyDown={mockHandleKeyDown}
|
|
153
|
+
displayName="Frank Miller"
|
|
154
|
+
displaySecondary="frank@example.com"
|
|
155
|
+
/>
|
|
156
|
+
<ParticipantItem
|
|
157
|
+
participant={{
|
|
158
|
+
id: '3',
|
|
159
|
+
name: 'Grace Lee',
|
|
160
|
+
image: 'https://picsum.photos/id/91/200/200'
|
|
161
|
+
}}
|
|
162
|
+
handleSelectParticipant={mockHandleSelect}
|
|
163
|
+
handleKeyDown={mockHandleKeyDown}
|
|
164
|
+
displayName="Grace Lee"
|
|
165
|
+
displaySecondary="grace@example.com"
|
|
166
|
+
/>
|
|
167
|
+
<ParticipantItem
|
|
168
|
+
participant={{ id: '4', name: 'Henry Taylor' }}
|
|
169
|
+
handleSelectParticipant={mockHandleSelect}
|
|
170
|
+
handleKeyDown={mockHandleKeyDown}
|
|
171
|
+
displayName="Henry Taylor"
|
|
172
|
+
displaySecondary="henry@example.com"
|
|
173
|
+
/>
|
|
174
|
+
<ParticipantItem
|
|
175
|
+
participant={{
|
|
176
|
+
id: '5',
|
|
177
|
+
name: 'Iris Chen',
|
|
178
|
+
image: 'https://picsum.photos/id/177/200/200'
|
|
179
|
+
}}
|
|
180
|
+
handleSelectParticipant={mockHandleSelect}
|
|
181
|
+
handleKeyDown={mockHandleKeyDown}
|
|
182
|
+
displayName="Iris Chen"
|
|
183
|
+
displaySecondary="iris@example.com"
|
|
184
|
+
/>
|
|
185
|
+
</div>
|
|
186
|
+
),
|
|
187
|
+
};
|
|
188
|
+
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { Participant } from '../../types';
|
|
3
|
+
import { SpinnerGapIcon } from '@phosphor-icons/react/dist/csr/SpinnerGap';
|
|
4
|
+
import { ChatCircleDotsIcon } from '@phosphor-icons/react/dist/csr/ChatCircleDots';
|
|
5
|
+
import { Avatar } from '../Avatar';
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
type ParticipantItemProps = {
|
|
9
|
+
participant: Participant;
|
|
10
|
+
handleSelectParticipant: (participant: Participant) => void;
|
|
11
|
+
handleKeyDown: (event: React.KeyboardEvent, participant: Participant) => void;
|
|
12
|
+
displayName: string;
|
|
13
|
+
displaySecondary?: string;
|
|
14
|
+
startingChatWithId?: string | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant, handleSelectParticipant, handleKeyDown, displayName, displaySecondary, startingChatWithId }) => (
|
|
18
|
+
<li key={participant.id}>
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={() => handleSelectParticipant(participant)}
|
|
22
|
+
onKeyDown={(e) => handleKeyDown(e, participant)}
|
|
23
|
+
className="w-full px-4 py-3 hover:bg-sand transition-colors border-b border-sand text-left focus-ring"
|
|
24
|
+
>
|
|
25
|
+
<div className="flex items-center justify-between">
|
|
26
|
+
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
27
|
+
{/* Avatar */}
|
|
28
|
+
<Avatar
|
|
29
|
+
id={participant.id}
|
|
30
|
+
name={displayName}
|
|
31
|
+
image={participant.image}
|
|
32
|
+
size={40}
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
{/* Info */}
|
|
36
|
+
<div className="flex-1 min-w-0">
|
|
37
|
+
<h4 className="text-sm font-medium text-charcoal truncate">
|
|
38
|
+
{displayName}
|
|
39
|
+
</h4>
|
|
40
|
+
{displaySecondary && (
|
|
41
|
+
<p className="text-xs text-stone truncate">
|
|
42
|
+
{displaySecondary}
|
|
43
|
+
</p>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
</div>
|
|
47
|
+
|
|
48
|
+
{/* Icon */}
|
|
49
|
+
<div className="flex-shrink-0">
|
|
50
|
+
{startingChatWithId === participant.id ? (
|
|
51
|
+
<SpinnerGapIcon className="h-5 w-5 text-primary animate-spin" />
|
|
52
|
+
) : (
|
|
53
|
+
<ChatCircleDotsIcon className="h-5 w-5 text-stone" />
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</button>
|
|
58
|
+
</li>
|
|
59
|
+
)
|