@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.
- package/dist/index.js +149 -140
- package/dist/index.js.map +1 -1
- package/package.json +5 -4
- 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 +35 -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 +121 -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 +58 -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,54 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from '@storybook/react'
|
|
2
|
+
import { ParticipantPicker } from './index'
|
|
3
|
+
import { mockParticipantSource } from '../../stories/mocks'
|
|
4
|
+
import React from 'react'
|
|
5
|
+
|
|
6
|
+
type ComponentProps = React.ComponentProps<typeof ParticipantPicker>
|
|
7
|
+
|
|
8
|
+
const meta: Meta<ComponentProps> = {
|
|
9
|
+
title: 'ParticipantPicker',
|
|
10
|
+
component: ParticipantPicker,
|
|
11
|
+
parameters: {
|
|
12
|
+
layout: 'fullscreen',
|
|
13
|
+
},
|
|
14
|
+
}
|
|
15
|
+
export default meta
|
|
16
|
+
|
|
17
|
+
const Template: StoryFn<ComponentProps> = (args) => {
|
|
18
|
+
return (
|
|
19
|
+
<div className="h-screen w-full">
|
|
20
|
+
<ParticipantPicker {...args} />
|
|
21
|
+
</div>
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const Default: StoryFn<ComponentProps> = Template.bind({})
|
|
26
|
+
Default.args = {
|
|
27
|
+
participantSource: mockParticipantSource,
|
|
28
|
+
participantLabel: 'participants',
|
|
29
|
+
searchPlaceholder: 'Search participants...',
|
|
30
|
+
onClose: () => console.log('Close clicked'),
|
|
31
|
+
onSelectParticipant: (participant) => console.log('Selected:', participant),
|
|
32
|
+
existingParticipantIds: new Set(),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export const WithExistingParticipants: StoryFn<ComponentProps> = Template.bind({})
|
|
36
|
+
WithExistingParticipants.args = {
|
|
37
|
+
participantSource: mockParticipantSource,
|
|
38
|
+
participantLabel: 'participants',
|
|
39
|
+
searchPlaceholder: 'Search participants...',
|
|
40
|
+
onClose: () => console.log('Close clicked'),
|
|
41
|
+
onSelectParticipant: (participant) => console.log('Selected:', participant),
|
|
42
|
+
existingParticipantIds: new Set(['participant-1', 'participant-3']),
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const CustomLabels: StoryFn<ComponentProps> = Template.bind({})
|
|
46
|
+
CustomLabels.args = {
|
|
47
|
+
participantSource: mockParticipantSource,
|
|
48
|
+
participantLabel: 'followers',
|
|
49
|
+
searchPlaceholder: 'Search followers...',
|
|
50
|
+
onClose: () => console.log('Close clicked'),
|
|
51
|
+
onSelectParticipant: (participant) => console.log('Selected:', participant),
|
|
52
|
+
existingParticipantIds: new Set(),
|
|
53
|
+
}
|
|
54
|
+
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { ChatCircleDotsIcon } from "@phosphor-icons/react/dist/csr/ChatCircleDots";
|
|
3
|
+
import classNames from 'classnames';
|
|
4
|
+
|
|
5
|
+
import type { ParticipantPickerProps, Participant } from '../../types';
|
|
6
|
+
import { CloseButton } from '../CloseButton';
|
|
7
|
+
import { SearchInput } from '../SearchInput';
|
|
8
|
+
import Loading from '../Loading';
|
|
9
|
+
import { ParticipantItem } from './ParticipantItem';
|
|
10
|
+
import { useMessagingContext } from '../../providers/MessagingProvider';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generic participant picker component for starting conversations
|
|
14
|
+
*/
|
|
15
|
+
export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
16
|
+
participantSource,
|
|
17
|
+
onSelectParticipant,
|
|
18
|
+
onClose,
|
|
19
|
+
existingParticipantIds = new Set(),
|
|
20
|
+
participantLabel = 'participants',
|
|
21
|
+
searchPlaceholder = 'Search participants...',
|
|
22
|
+
className,
|
|
23
|
+
}) => {
|
|
24
|
+
const { debug } = useMessagingContext();
|
|
25
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
26
|
+
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
// Track if we've already loaded participants to prevent repeated loading
|
|
32
|
+
const loadedRef = useRef(false);
|
|
33
|
+
|
|
34
|
+
// Load participants initially - wait for participantSource to finish loading first
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Wait for the participantSource to finish loading before we try to load participants
|
|
37
|
+
if (participantSource.loading) {
|
|
38
|
+
if (debug) {
|
|
39
|
+
console.log('[ParticipantPicker] Waiting for participant source to finish loading...');
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (loadedRef.current) return; // Prevent multiple loads
|
|
45
|
+
|
|
46
|
+
const loadInitialParticipants = async () => {
|
|
47
|
+
if (debug) {
|
|
48
|
+
console.log('[ParticipantPicker] Loading initial participants...');
|
|
49
|
+
}
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await participantSource.loadParticipants({
|
|
55
|
+
search: '', // Load all participants initially
|
|
56
|
+
limit: 100
|
|
57
|
+
});
|
|
58
|
+
setParticipants(result.participants);
|
|
59
|
+
loadedRef.current = true; // Mark as loaded
|
|
60
|
+
if (debug) {
|
|
61
|
+
console.log('[ParticipantPicker] Participants loaded successfully:', result.participants.length);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
|
|
65
|
+
setError(errorMessage);
|
|
66
|
+
console.error('[ParticipantPicker] Failed to load participants:', err);
|
|
67
|
+
// Don't mark as loaded on error, allow retry
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
loadInitialParticipants();
|
|
74
|
+
}, [participantSource.loading, debug]); // Re-run when loading state changes
|
|
75
|
+
|
|
76
|
+
// Filter participants by search query and existing participants
|
|
77
|
+
const availableParticipants = participants
|
|
78
|
+
.filter(participant => !existingParticipantIds.has(participant.id))
|
|
79
|
+
.filter(participant => {
|
|
80
|
+
if (!searchQuery) return true;
|
|
81
|
+
const searchLower = searchQuery.toLowerCase();
|
|
82
|
+
return (
|
|
83
|
+
participant.name.toLowerCase().includes(searchLower) ||
|
|
84
|
+
participant.email?.toLowerCase().includes(searchLower) ||
|
|
85
|
+
false
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const handleSelectParticipant = useCallback(async (participant: Participant) => {
|
|
90
|
+
if (startingChatWithId) return; // Prevent multiple clicks
|
|
91
|
+
|
|
92
|
+
setStartingChatWithId(participant.id);
|
|
93
|
+
try {
|
|
94
|
+
await onSelectParticipant(participant);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[ParticipantPicker] Failed to start chat:', error);
|
|
97
|
+
// Reset the loading state on error
|
|
98
|
+
setStartingChatWithId(null);
|
|
99
|
+
}
|
|
100
|
+
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
101
|
+
}, [onSelectParticipant, startingChatWithId]);
|
|
102
|
+
|
|
103
|
+
const handleKeyDown = (event: React.KeyboardEvent, participant: Participant) => {
|
|
104
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
handleSelectParticipant(participant);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={classNames('flex flex-col h-full', className)}>
|
|
112
|
+
{/* Header */}
|
|
113
|
+
<div className="px-4 py-4 border-b border-sand bg-chalk">
|
|
114
|
+
<div className="flex items-center justify-between mb-3">
|
|
115
|
+
<h2 className="text-lg font-semibold text-charcoal">
|
|
116
|
+
Start a new Conversation
|
|
117
|
+
</h2>
|
|
118
|
+
<CloseButton onClick={onClose} />
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<p className="text-xs text-stone mb-3">
|
|
122
|
+
Select a {participantLabel.slice(0, -1)} to start messaging ({availableParticipants.length} available)
|
|
123
|
+
{participantSource.totalCount !== undefined && ` • ${participantSource.totalCount} ${participantLabel} total`}
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
<SearchInput
|
|
127
|
+
searchQuery={searchQuery}
|
|
128
|
+
setSearchQuery={setSearchQuery}
|
|
129
|
+
placeholder={searchPlaceholder}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Error State */}
|
|
134
|
+
{error && (
|
|
135
|
+
<div className="p-4 text-sm text-danger bg-danger-alt">
|
|
136
|
+
Error loading {participantLabel}: {error}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Participants List */}
|
|
141
|
+
<div className="flex-1 overflow-auto">
|
|
142
|
+
{loading && availableParticipants.length === 0 ? (
|
|
143
|
+
<div className="h-32 flex items-center justify-center">
|
|
144
|
+
<div className="flex items-center space-x-2">
|
|
145
|
+
<div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
146
|
+
<span className="text-sm text-stone">Loading {participantLabel}...</span>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
) : availableParticipants.length === 0 ? (
|
|
150
|
+
<div className="p-6 text-center">
|
|
151
|
+
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sand">
|
|
152
|
+
<ChatCircleDotsIcon className="h-8 w-8 text-charcoal" />
|
|
153
|
+
</div>
|
|
154
|
+
<h3 className="text-sm font-semibold text-charcoal mb-2">
|
|
155
|
+
{searchQuery
|
|
156
|
+
? `No ${participantLabel} found`
|
|
157
|
+
: participants.length > 0
|
|
158
|
+
? `Already chatting with all ${participantLabel}`
|
|
159
|
+
: `No ${participantLabel} yet`}
|
|
160
|
+
</h3>
|
|
161
|
+
<p className="text-xs text-stone">
|
|
162
|
+
{searchQuery
|
|
163
|
+
? 'Try a different search term'
|
|
164
|
+
: participants.length > 0
|
|
165
|
+
? `You have existing conversations with all your ${participantLabel}`
|
|
166
|
+
: `${participantLabel.charAt(0).toUpperCase() + participantLabel.slice(1)} will appear here`}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<ul className="space-y-0">
|
|
171
|
+
{availableParticipants.map((participant) => {
|
|
172
|
+
const displayName = participant.name || participant.email || participant.id;
|
|
173
|
+
const displaySecondary = participant.email && participant.name ? participant.email : participant.phone;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<ParticipantItem key={participant.id} participant={participant} handleSelectParticipant={handleSelectParticipant} handleKeyDown={handleKeyDown} displayName={displayName} displaySecondary={displaySecondary} />
|
|
177
|
+
);
|
|
178
|
+
})}
|
|
179
|
+
|
|
180
|
+
{/* Loading indicator */}
|
|
181
|
+
{loading && (
|
|
182
|
+
<li className="p-4 flex justify-center">
|
|
183
|
+
<div className="flex items-center space-x-2">
|
|
184
|
+
<Loading className='w-6 h-6' />
|
|
185
|
+
<span className="text-sm text-stone">Loading more...</span>
|
|
186
|
+
</div>
|
|
187
|
+
</li>
|
|
188
|
+
)}
|
|
189
|
+
</ul>
|
|
190
|
+
)}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
);
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, { useCallback, useEffect, useState, useRef } from 'react';
|
|
2
|
+
import { ChatCircleDotsIcon } from "@phosphor-icons/react/dist/csr/ChatCircleDots";
|
|
3
|
+
import { SpinnerGapIcon } from "@phosphor-icons/react/dist/csr/SpinnerGap";
|
|
4
|
+
import classNames from 'classnames';
|
|
5
|
+
|
|
6
|
+
import type { ParticipantPickerProps, Participant } from '../../types';
|
|
7
|
+
import { CloseButton } from '../CloseButton';
|
|
8
|
+
import { SearchInput } from '../SearchInput';
|
|
9
|
+
import { useMessagingContext } from '../../providers/MessagingProvider';
|
|
10
|
+
import { Avatar } from '../Avatar';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generic participant picker component for starting conversations
|
|
14
|
+
*/
|
|
15
|
+
export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
|
|
16
|
+
participantSource,
|
|
17
|
+
onSelectParticipant,
|
|
18
|
+
onClose,
|
|
19
|
+
existingParticipantIds = new Set(),
|
|
20
|
+
participantLabel = 'participants',
|
|
21
|
+
searchPlaceholder = 'Search participants...',
|
|
22
|
+
className,
|
|
23
|
+
}) => {
|
|
24
|
+
const { debug } = useMessagingContext();
|
|
25
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
26
|
+
const [participants, setParticipants] = useState<Participant[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(false);
|
|
28
|
+
const [error, setError] = useState<string | null>(null);
|
|
29
|
+
const [startingChatWithId, setStartingChatWithId] = useState<string | null>(null);
|
|
30
|
+
|
|
31
|
+
// Track if we've already loaded participants to prevent repeated loading
|
|
32
|
+
const loadedRef = useRef(false);
|
|
33
|
+
|
|
34
|
+
// Load participants initially - wait for participantSource to finish loading first
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
// Wait for the participantSource to finish loading before we try to load participants
|
|
37
|
+
if (participantSource.loading) {
|
|
38
|
+
if (debug) {
|
|
39
|
+
console.log('[ParticipantPicker] Waiting for participant source to finish loading...');
|
|
40
|
+
}
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (loadedRef.current) return; // Prevent multiple loads
|
|
45
|
+
|
|
46
|
+
const loadInitialParticipants = async () => {
|
|
47
|
+
if (debug) {
|
|
48
|
+
console.log('[ParticipantPicker] Loading initial participants...');
|
|
49
|
+
}
|
|
50
|
+
setLoading(true);
|
|
51
|
+
setError(null);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await participantSource.loadParticipants({
|
|
55
|
+
search: '', // Load all participants initially
|
|
56
|
+
limit: 100
|
|
57
|
+
});
|
|
58
|
+
setParticipants(result.participants);
|
|
59
|
+
loadedRef.current = true; // Mark as loaded
|
|
60
|
+
if (debug) {
|
|
61
|
+
console.log('[ParticipantPicker] Participants loaded successfully:', result.participants.length);
|
|
62
|
+
}
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const errorMessage = err instanceof Error ? err.message : 'Failed to load participants';
|
|
65
|
+
setError(errorMessage);
|
|
66
|
+
console.error('[ParticipantPicker] Failed to load participants:', err);
|
|
67
|
+
// Don't mark as loaded on error, allow retry
|
|
68
|
+
} finally {
|
|
69
|
+
setLoading(false);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
loadInitialParticipants();
|
|
74
|
+
}, [participantSource.loading, debug]); // Re-run when loading state changes
|
|
75
|
+
|
|
76
|
+
// Filter participants by search query and existing participants
|
|
77
|
+
const availableParticipants = participants
|
|
78
|
+
.filter(participant => !existingParticipantIds.has(participant.id))
|
|
79
|
+
.filter(participant => {
|
|
80
|
+
if (!searchQuery) return true;
|
|
81
|
+
const searchLower = searchQuery.toLowerCase();
|
|
82
|
+
return (
|
|
83
|
+
participant.name.toLowerCase().includes(searchLower) ||
|
|
84
|
+
participant.email?.toLowerCase().includes(searchLower) ||
|
|
85
|
+
false
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
const handleSelectParticipant = useCallback(async (participant: Participant) => {
|
|
90
|
+
if (startingChatWithId) return; // Prevent multiple clicks
|
|
91
|
+
|
|
92
|
+
setStartingChatWithId(participant.id);
|
|
93
|
+
try {
|
|
94
|
+
await onSelectParticipant(participant);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error('[ParticipantPicker] Failed to start chat:', error);
|
|
97
|
+
// Reset the loading state on error
|
|
98
|
+
setStartingChatWithId(null);
|
|
99
|
+
}
|
|
100
|
+
// Note: Don't reset startingChatWithId on success because the dialog will close
|
|
101
|
+
}, [onSelectParticipant, startingChatWithId]);
|
|
102
|
+
|
|
103
|
+
const handleKeyDown = (event: React.KeyboardEvent, participant: Participant) => {
|
|
104
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
105
|
+
event.preventDefault();
|
|
106
|
+
handleSelectParticipant(participant);
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div className={classNames('flex flex-col h-full', className)}>
|
|
112
|
+
{/* Header */}
|
|
113
|
+
<div className="px-4 py-4 border-b border-sand bg-chalk">
|
|
114
|
+
<div className="flex items-center justify-between mb-3">
|
|
115
|
+
<h2 className="text-lg font-semibold text-charcoal">
|
|
116
|
+
Start a new Conversation
|
|
117
|
+
</h2>
|
|
118
|
+
<CloseButton onClick={onClose} />
|
|
119
|
+
</div>
|
|
120
|
+
|
|
121
|
+
<p className="text-xs text-stone mb-3">
|
|
122
|
+
Select a {participantLabel.slice(0, -1)} to start messaging ({availableParticipants.length} available)
|
|
123
|
+
{participantSource.totalCount !== undefined && ` • ${participantSource.totalCount} ${participantLabel} total`}
|
|
124
|
+
</p>
|
|
125
|
+
|
|
126
|
+
<SearchInput
|
|
127
|
+
searchQuery={searchQuery}
|
|
128
|
+
setSearchQuery={setSearchQuery}
|
|
129
|
+
placeholder={searchPlaceholder}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Error State */}
|
|
134
|
+
{error && (
|
|
135
|
+
<div className="p-4 text-sm text-danger bg-danger-alt">
|
|
136
|
+
Error loading {participantLabel}: {error}
|
|
137
|
+
</div>
|
|
138
|
+
)}
|
|
139
|
+
|
|
140
|
+
{/* Participants List */}
|
|
141
|
+
<div className="flex-1 overflow-auto">
|
|
142
|
+
{loading && availableParticipants.length === 0 ? (
|
|
143
|
+
<div className="h-32 flex items-center justify-center">
|
|
144
|
+
<div className="flex items-center space-x-2">
|
|
145
|
+
<div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
146
|
+
<span className="text-sm text-stone">Loading {participantLabel}...</span>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
) : availableParticipants.length === 0 ? (
|
|
150
|
+
<div className="p-6 text-center">
|
|
151
|
+
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-sand">
|
|
152
|
+
<ChatCircleDotsIcon className="h-8 w-8 text-charcoal" />
|
|
153
|
+
</div>
|
|
154
|
+
<h3 className="text-sm font-semibold text-charcoal mb-2">
|
|
155
|
+
{searchQuery
|
|
156
|
+
? `No ${participantLabel} found`
|
|
157
|
+
: participants.length > 0
|
|
158
|
+
? `Already chatting with all ${participantLabel}`
|
|
159
|
+
: `No ${participantLabel} yet`}
|
|
160
|
+
</h3>
|
|
161
|
+
<p className="text-xs text-stone">
|
|
162
|
+
{searchQuery
|
|
163
|
+
? 'Try a different search term'
|
|
164
|
+
: participants.length > 0
|
|
165
|
+
? `You have existing conversations with all your ${participantLabel}`
|
|
166
|
+
: `${participantLabel.charAt(0).toUpperCase() + participantLabel.slice(1)} will appear here`}
|
|
167
|
+
</p>
|
|
168
|
+
</div>
|
|
169
|
+
) : (
|
|
170
|
+
<ul className="space-y-0">
|
|
171
|
+
{availableParticipants.map((participant) => {
|
|
172
|
+
const displayName = participant.name || participant.email || participant.id;
|
|
173
|
+
const displaySecondary = participant.email && participant.name ? participant.email : participant.phone;
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<li key={participant.id}>
|
|
177
|
+
<button
|
|
178
|
+
type="button"
|
|
179
|
+
onClick={() => handleSelectParticipant(participant)}
|
|
180
|
+
onKeyDown={(e) => handleKeyDown(e, participant)}
|
|
181
|
+
className="w-full px-4 py-3 hover:bg-sand transition-colors border-b border-sand text-left focus:outline-none focus:ring-2 focus:ring-black"
|
|
182
|
+
>
|
|
183
|
+
<div className="flex items-center justify-between">
|
|
184
|
+
<div className="flex items-center space-x-3 flex-1 min-w-0">
|
|
185
|
+
{/* Avatar */}
|
|
186
|
+
<Avatar
|
|
187
|
+
id={participant.id}
|
|
188
|
+
name={displayName}
|
|
189
|
+
image={participant.image}
|
|
190
|
+
size={40}
|
|
191
|
+
/>
|
|
192
|
+
|
|
193
|
+
{/* Info */}
|
|
194
|
+
<div className="flex-1 min-w-0">
|
|
195
|
+
<h4 className="text-sm font-medium text-charcoal truncate">
|
|
196
|
+
{displayName}
|
|
197
|
+
</h4>
|
|
198
|
+
{displaySecondary && (
|
|
199
|
+
<p className="text-xs text-stone truncate">
|
|
200
|
+
{displaySecondary}
|
|
201
|
+
</p>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Icon */}
|
|
207
|
+
<div className="flex-shrink-0">
|
|
208
|
+
{startingChatWithId === participant.id ? (
|
|
209
|
+
<SpinnerGapIcon className="h-5 w-5 text-primary animate-spin" />
|
|
210
|
+
) : (
|
|
211
|
+
<ChatCircleDotsIcon className="h-5 w-5 text-stone" />
|
|
212
|
+
)}
|
|
213
|
+
</div>
|
|
214
|
+
</div>
|
|
215
|
+
</button>
|
|
216
|
+
</li>
|
|
217
|
+
);
|
|
218
|
+
})}
|
|
219
|
+
|
|
220
|
+
{/* Loading indicator */}
|
|
221
|
+
{loading && (
|
|
222
|
+
<li className="p-4 flex justify-center">
|
|
223
|
+
<div className="flex items-center space-x-2">
|
|
224
|
+
<div className="w-4 h-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
|
|
225
|
+
<span className="text-sm text-stone">Loading more...</span>
|
|
226
|
+
</div>
|
|
227
|
+
</li>
|
|
228
|
+
)}
|
|
229
|
+
</ul>
|
|
230
|
+
)}
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
);
|
|
234
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Meta, StoryFn } from '@storybook/react'
|
|
2
|
+
import { SearchInput } from '.'
|
|
3
|
+
import React from 'react'
|
|
4
|
+
|
|
5
|
+
type ComponentProps = React.ComponentProps<typeof SearchInput>
|
|
6
|
+
|
|
7
|
+
const meta: Meta<ComponentProps> = {
|
|
8
|
+
title: 'SearchInput',
|
|
9
|
+
component: SearchInput,
|
|
10
|
+
parameters: {
|
|
11
|
+
layout: 'centered',
|
|
12
|
+
},
|
|
13
|
+
}
|
|
14
|
+
export default meta
|
|
15
|
+
|
|
16
|
+
const Template: StoryFn<ComponentProps> = (args) => {
|
|
17
|
+
return (
|
|
18
|
+
<div className="p-12 w-[400px]">
|
|
19
|
+
<SearchInput {...args} />
|
|
20
|
+
</div>
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const Default: StoryFn<ComponentProps> = Template.bind({})
|
|
25
|
+
Default.args = {
|
|
26
|
+
placeholder: 'Search...',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const CustomPlaceholder: StoryFn<ComponentProps> = Template.bind({})
|
|
30
|
+
CustomPlaceholder.args = {
|
|
31
|
+
placeholder: 'Find a conversation...',
|
|
32
|
+
}
|
|
33
|
+
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
+
import { renderWithProviders, screen, userEvent } from '../../test/utils';
|
|
3
|
+
import { SearchInput } from './index';
|
|
4
|
+
|
|
5
|
+
describe('SearchInput', () => {
|
|
6
|
+
describe('Rendering', () => {
|
|
7
|
+
it('renders with placeholder', () => {
|
|
8
|
+
renderWithProviders(
|
|
9
|
+
<SearchInput
|
|
10
|
+
searchQuery=""
|
|
11
|
+
setSearchQuery={vi.fn()}
|
|
12
|
+
placeholder="Search messages..."
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
expect(screen.getByPlaceholderText('Search messages...')).toBeInTheDocument();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('renders with search icon', () => {
|
|
19
|
+
renderWithProviders(
|
|
20
|
+
<SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
21
|
+
);
|
|
22
|
+
const searchIcon = document.querySelector('svg');
|
|
23
|
+
expect(searchIcon).toBeInTheDocument();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('displays the current value', () => {
|
|
27
|
+
renderWithProviders(
|
|
28
|
+
<SearchInput searchQuery="test query" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
29
|
+
);
|
|
30
|
+
const input = screen.getByRole('textbox');
|
|
31
|
+
expect(input).toHaveValue('test query');
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe('User Interaction', () => {
|
|
36
|
+
it('calls setSearchQuery when user types', async () => {
|
|
37
|
+
const handleChange = vi.fn();
|
|
38
|
+
const user = userEvent.setup();
|
|
39
|
+
|
|
40
|
+
renderWithProviders(
|
|
41
|
+
<SearchInput searchQuery="" setSearchQuery={handleChange} placeholder="Search" />
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
const input = screen.getByRole('textbox');
|
|
45
|
+
await user.type(input, 'hello');
|
|
46
|
+
|
|
47
|
+
expect(handleChange).toHaveBeenCalledTimes(5); // Once per character
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('Clear Functionality', () => {
|
|
52
|
+
it('shows clear button when there is a value', () => {
|
|
53
|
+
renderWithProviders(
|
|
54
|
+
<SearchInput searchQuery="test" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
const clearButton = screen.getByLabelText(/clear/i);
|
|
58
|
+
expect(clearButton).toBeInTheDocument();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('does not show clear button when value is empty', () => {
|
|
62
|
+
renderWithProviders(
|
|
63
|
+
<SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
64
|
+
);
|
|
65
|
+
|
|
66
|
+
const clearButton = screen.queryByLabelText(/clear/i);
|
|
67
|
+
expect(clearButton).not.toBeInTheDocument();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('calls setSearchQuery with empty string when clear is clicked', async () => {
|
|
71
|
+
const handleChange = vi.fn();
|
|
72
|
+
const user = userEvent.setup();
|
|
73
|
+
|
|
74
|
+
renderWithProviders(
|
|
75
|
+
<SearchInput searchQuery="test" setSearchQuery={handleChange} placeholder="Search" />
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const clearButton = screen.getByLabelText(/clear/i);
|
|
79
|
+
await user.click(clearButton);
|
|
80
|
+
|
|
81
|
+
expect(handleChange).toHaveBeenCalledWith('');
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('Accessibility', () => {
|
|
86
|
+
it('clear button has accessible label', () => {
|
|
87
|
+
renderWithProviders(
|
|
88
|
+
<SearchInput searchQuery="test" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
const clearButton = screen.getByRole('button', { name: /clear search/i });
|
|
92
|
+
expect(clearButton).toBeInTheDocument();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('is keyboard navigable', async () => {
|
|
96
|
+
const user = userEvent.setup();
|
|
97
|
+
|
|
98
|
+
renderWithProviders(
|
|
99
|
+
<SearchInput searchQuery="" setSearchQuery={vi.fn()} placeholder="Search" />
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
await user.tab();
|
|
103
|
+
const input = screen.getByRole('textbox');
|
|
104
|
+
expect(input).toHaveFocus();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import React, { useRef } from 'react';
|
|
2
|
+
import { IconButton } from '../IconButton';
|
|
3
|
+
import { MagnifyingGlassIcon } from "@phosphor-icons/react/dist/csr/MagnifyingGlass";
|
|
4
|
+
import { XIcon } from "@phosphor-icons/react/dist/csr/X";
|
|
5
|
+
|
|
6
|
+
interface SearchInputProps {
|
|
7
|
+
searchQuery: string;
|
|
8
|
+
setSearchQuery: (value: string) => void;
|
|
9
|
+
placeholder: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function SearchInput({
|
|
13
|
+
searchQuery,
|
|
14
|
+
setSearchQuery,
|
|
15
|
+
placeholder,
|
|
16
|
+
}: SearchInputProps) {
|
|
17
|
+
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<div className="relative">
|
|
21
|
+
<MagnifyingGlassIcon
|
|
22
|
+
className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-stone"
|
|
23
|
+
weight="bold"
|
|
24
|
+
/>
|
|
25
|
+
|
|
26
|
+
<input
|
|
27
|
+
ref={searchInputRef}
|
|
28
|
+
type="text"
|
|
29
|
+
placeholder={placeholder}
|
|
30
|
+
value={searchQuery}
|
|
31
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
32
|
+
className="w-full pl-10 pr-10 py-3 text-sm border border-sand rounded-xl focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent"
|
|
33
|
+
/>
|
|
34
|
+
|
|
35
|
+
{searchQuery && (
|
|
36
|
+
<IconButton
|
|
37
|
+
label="Clear search"
|
|
38
|
+
onClick={() => {
|
|
39
|
+
setSearchQuery('');
|
|
40
|
+
searchInputRef.current?.focus();
|
|
41
|
+
}}
|
|
42
|
+
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 text-stone hover:text-charcoal"
|
|
43
|
+
>
|
|
44
|
+
<XIcon className="h-4 w-4" weight="bold" />
|
|
45
|
+
</IconButton>
|
|
46
|
+
)}
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { useMessagingContext } from '../providers/MessagingProvider';
|
|
2
|
+
import type { MessagingContextValue } from '../providers/MessagingProvider';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Hook to access messaging service and state
|
|
6
|
+
*/
|
|
7
|
+
export const useMessaging = (): MessagingContextValue => {
|
|
8
|
+
return useMessagingContext();
|
|
9
|
+
};
|