@linktr.ee/messaging-react 1.0.0 → 1.0.2

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 (42) hide show
  1. package/dist/index.js +149 -140
  2. package/dist/index.js.map +1 -1
  3. package/package.json +5 -4
  4. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  5. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  6. package/src/components/ActionButton/index.tsx +33 -0
  7. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  8. package/src/components/Avatar/avatarColors.ts +35 -0
  9. package/src/components/Avatar/index.tsx +64 -0
  10. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  11. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  12. package/src/components/ChannelList/CustomChannelPreview.tsx +121 -0
  13. package/src/components/ChannelList/index.tsx +129 -0
  14. package/src/components/ChannelView.tsx +422 -0
  15. package/src/components/CloseButton/index.tsx +16 -0
  16. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  17. package/src/components/IconButton/index.tsx +32 -0
  18. package/src/components/Loading/Loading.stories.tsx +24 -0
  19. package/src/components/Loading/index.tsx +50 -0
  20. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  21. package/src/components/MessagingShell/EmptyState.tsx +58 -0
  22. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  23. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  24. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  25. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  26. package/src/components/MessagingShell/index.tsx +298 -0
  27. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  28. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  29. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  30. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  31. package/src/components/ParticipantPicker/index.tsx +234 -0
  32. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  33. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  34. package/src/components/SearchInput/index.tsx +50 -0
  35. package/src/hooks/useMessaging.ts +9 -0
  36. package/src/hooks/useParticipants.ts +92 -0
  37. package/src/index.ts +26 -0
  38. package/src/providers/MessagingProvider.tsx +282 -0
  39. package/src/stories/mocks.tsx +157 -0
  40. package/src/test/setup.ts +30 -0
  41. package/src/test/utils.tsx +23 -0
  42. 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
+ )