@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.
Files changed (40) hide show
  1. package/package.json +3 -2
  2. package/src/components/ActionButton/ActionButton.stories.tsx +46 -0
  3. package/src/components/ActionButton/ActionButton.test.tsx +112 -0
  4. package/src/components/ActionButton/index.tsx +33 -0
  5. package/src/components/Avatar/Avatar.stories.tsx +144 -0
  6. package/src/components/Avatar/avatarColors.ts +36 -0
  7. package/src/components/Avatar/index.tsx +64 -0
  8. package/src/components/ChannelList/ChannelList.stories.tsx +48 -0
  9. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +303 -0
  10. package/src/components/ChannelList/CustomChannelPreview.tsx +114 -0
  11. package/src/components/ChannelList/index.tsx +129 -0
  12. package/src/components/ChannelView.tsx +422 -0
  13. package/src/components/CloseButton/index.tsx +16 -0
  14. package/src/components/IconButton/IconButton.stories.tsx +40 -0
  15. package/src/components/IconButton/index.tsx +32 -0
  16. package/src/components/Loading/Loading.stories.tsx +24 -0
  17. package/src/components/Loading/index.tsx +50 -0
  18. package/src/components/MessagingShell/EmptyState.stories.tsx +38 -0
  19. package/src/components/MessagingShell/EmptyState.tsx +55 -0
  20. package/src/components/MessagingShell/ErrorState.stories.tsx +42 -0
  21. package/src/components/MessagingShell/ErrorState.tsx +33 -0
  22. package/src/components/MessagingShell/LoadingState.stories.tsx +26 -0
  23. package/src/components/MessagingShell/LoadingState.tsx +15 -0
  24. package/src/components/MessagingShell/index.tsx +298 -0
  25. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +188 -0
  26. package/src/components/ParticipantPicker/ParticipantItem.tsx +59 -0
  27. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +54 -0
  28. package/src/components/ParticipantPicker/ParticipantPicker.tsx +196 -0
  29. package/src/components/ParticipantPicker/index.tsx +234 -0
  30. package/src/components/SearchInput/SearchInput.stories.tsx +33 -0
  31. package/src/components/SearchInput/SearchInput.test.tsx +108 -0
  32. package/src/components/SearchInput/index.tsx +50 -0
  33. package/src/hooks/useMessaging.ts +9 -0
  34. package/src/hooks/useParticipants.ts +92 -0
  35. package/src/index.ts +26 -0
  36. package/src/providers/MessagingProvider.tsx +282 -0
  37. package/src/stories/mocks.tsx +157 -0
  38. package/src/test/setup.ts +30 -0
  39. package/src/test/utils.tsx +23 -0
  40. package/src/types.ts +113 -0
@@ -0,0 +1,303 @@
1
+ import type { Meta, StoryFn } from '@storybook/react'
2
+ import CustomChannelPreview from './CustomChannelPreview'
3
+ import React from 'react'
4
+
5
+ type ComponentProps = React.ComponentProps<typeof CustomChannelPreview>
6
+
7
+ const meta: Meta<ComponentProps> = {
8
+ title: 'ChannelList/CustomChannelPreview',
9
+ component: CustomChannelPreview,
10
+ parameters: {
11
+ layout: 'centered'
12
+ },
13
+ decorators: [
14
+ (Story) => (
15
+ <Story />
16
+ )
17
+ ]
18
+ }
19
+ export default meta
20
+
21
+ // Mock user for the client
22
+ const mockUser = {
23
+ id: 'current-user',
24
+ name: 'Current User',
25
+ image: 'https://i.pravatar.cc/150?img=1',
26
+ }
27
+
28
+ // Helper to create a mock channel
29
+ const createMockChannel = (options: {
30
+ id: string
31
+ participantName: string
32
+ participantId: string
33
+ participantImage?: string
34
+ lastMessageText?: string
35
+ lastMessageTime?: Date
36
+ unreadCount?: number
37
+ }): any => {
38
+ const {
39
+ id,
40
+ participantName,
41
+ participantId,
42
+ participantImage,
43
+ lastMessageText = 'Hey! How are you doing?',
44
+ lastMessageTime = new Date(),
45
+ unreadCount = 0,
46
+ } = options
47
+
48
+ return {
49
+ id,
50
+ cid: `messaging:${id}`,
51
+ _client: {
52
+ userID: mockUser.id,
53
+ },
54
+ state: {
55
+ members: {
56
+ [mockUser.id]: {
57
+ user: mockUser,
58
+ user_id: mockUser.id,
59
+ },
60
+ [participantId]: {
61
+ user: {
62
+ id: participantId,
63
+ name: participantName,
64
+ image: participantImage,
65
+ },
66
+ user_id: participantId,
67
+ },
68
+ },
69
+ messages: lastMessageText ? [
70
+ {
71
+ id: `msg-${id}-1`,
72
+ text: lastMessageText,
73
+ created_at: lastMessageTime.toISOString(),
74
+ user: {
75
+ id: participantId,
76
+ name: participantName,
77
+ },
78
+ },
79
+ ] : [],
80
+ unreadCount,
81
+ },
82
+ }
83
+ }
84
+
85
+ const Template: StoryFn<ComponentProps> = (args) => {
86
+ return (
87
+ <div className="w-[360px]">
88
+ <CustomChannelPreview {...args} />
89
+ </div>
90
+ )
91
+ }
92
+
93
+ export const Default: StoryFn<ComponentProps> = Template.bind({})
94
+ Default.args = {
95
+ channel: createMockChannel({
96
+ id: 'channel-1',
97
+ participantName: 'Alice Johnson',
98
+ participantId: 'participant-1',
99
+ participantImage: 'https://i.pravatar.cc/150?img=2',
100
+ lastMessageText: 'Hey! How are you doing?',
101
+ lastMessageTime: new Date(),
102
+ }),
103
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
104
+ }
105
+
106
+ export const Selected: StoryFn<ComponentProps> = Template.bind({})
107
+ Selected.args = {
108
+ channel: createMockChannel({
109
+ id: 'channel-2',
110
+ participantName: 'Bob Smith',
111
+ participantId: 'participant-2',
112
+ participantImage: 'https://i.pravatar.cc/150?img=3',
113
+ lastMessageText: 'That sounds great!',
114
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
115
+ }),
116
+ selectedChannel: createMockChannel({
117
+ id: 'channel-2',
118
+ participantName: 'Bob Smith',
119
+ participantId: 'participant-2',
120
+ participantImage: 'https://i.pravatar.cc/150?img=3',
121
+ }),
122
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
123
+ }
124
+
125
+ export const WithUnreadMessages: StoryFn<ComponentProps> = Template.bind({})
126
+ WithUnreadMessages.args = {
127
+ channel: createMockChannel({
128
+ id: 'channel-3',
129
+ participantName: 'Carol Williams',
130
+ participantId: 'participant-3',
131
+ participantImage: 'https://i.pravatar.cc/150?img=4',
132
+ lastMessageText: 'Did you see my last message?',
133
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 15), // 15 minutes ago
134
+ unreadCount: 3,
135
+ }),
136
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
137
+ }
138
+
139
+ export const ManyUnreadMessages: StoryFn<ComponentProps> = Template.bind({})
140
+ ManyUnreadMessages.args = {
141
+ channel: createMockChannel({
142
+ id: 'channel-4',
143
+ participantName: 'David Brown',
144
+ participantId: 'participant-4',
145
+ participantImage: 'https://i.pravatar.cc/150?img=5',
146
+ lastMessageText: 'Please check this out!',
147
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
148
+ unreadCount: 127,
149
+ }),
150
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
151
+ }
152
+
153
+ export const NoAvatar: StoryFn<ComponentProps> = Template.bind({})
154
+ NoAvatar.args = {
155
+ channel: createMockChannel({
156
+ id: 'channel-5',
157
+ participantName: 'Emma Davis',
158
+ participantId: 'participant-5',
159
+ lastMessageText: 'Thanks for your help!',
160
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 30), // 30 minutes ago
161
+ }),
162
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
163
+ }
164
+
165
+ export const NoMessages: StoryFn<ComponentProps> = Template.bind({})
166
+ NoMessages.args = {
167
+ channel: createMockChannel({
168
+ id: 'channel-6',
169
+ participantName: 'Frank Miller',
170
+ participantId: 'participant-6',
171
+ participantImage: 'https://i.pravatar.cc/150?img=6',
172
+ lastMessageText: '',
173
+ }),
174
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
175
+ }
176
+
177
+ export const LongMessage: StoryFn<ComponentProps> = Template.bind({})
178
+ LongMessage.args = {
179
+ channel: createMockChannel({
180
+ id: 'channel-7',
181
+ participantName: 'Grace Lee',
182
+ participantId: 'participant-7',
183
+ participantImage: 'https://i.pravatar.cc/150?img=7',
184
+ lastMessageText: 'This is a very long message that should be truncated because it contains way too much text to display in the preview. We want to make sure the component handles this gracefully.',
185
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
186
+ }),
187
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
188
+ }
189
+
190
+ export const LongName: StoryFn<ComponentProps> = Template.bind({})
191
+ LongName.args = {
192
+ channel: createMockChannel({
193
+ id: 'channel-8',
194
+ participantName: 'Alexander Christopher Wellington-Montgomery III',
195
+ participantId: 'participant-8',
196
+ lastMessageText: 'Nice to meet you!',
197
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 45), // 45 minutes ago
198
+ }),
199
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
200
+ }
201
+
202
+ export const SelectedWithUnread: StoryFn<ComponentProps> = Template.bind({})
203
+ SelectedWithUnread.args = {
204
+ channel: createMockChannel({
205
+ id: 'channel-9',
206
+ participantName: 'Helen Park',
207
+ participantId: 'participant-9',
208
+ participantImage: 'https://i.pravatar.cc/150?img=8',
209
+ lastMessageText: 'Important update!',
210
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 10), // 10 minutes ago
211
+ unreadCount: 5,
212
+ }),
213
+ selectedChannel: createMockChannel({
214
+ id: 'channel-9',
215
+ participantName: 'Helen Park',
216
+ participantId: 'participant-9',
217
+ }),
218
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
219
+ }
220
+
221
+ export const MultipleChannels: StoryFn = () => {
222
+ const [selectedChannelId, setSelectedChannelId] = React.useState<string | null>('channel-2')
223
+
224
+ const channels = [
225
+ createMockChannel({
226
+ id: 'channel-1',
227
+ participantName: 'Alice Johnson',
228
+ participantId: 'participant-1',
229
+ participantImage: 'https://i.pravatar.cc/150?img=2',
230
+ lastMessageText: 'Hey! How are you doing?',
231
+ lastMessageTime: new Date(),
232
+ unreadCount: 2,
233
+ }),
234
+ createMockChannel({
235
+ id: 'channel-2',
236
+ participantName: 'Bob Smith',
237
+ participantId: 'participant-2',
238
+ participantImage: 'https://i.pravatar.cc/150?img=3',
239
+ lastMessageText: 'That sounds great!',
240
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 5), // 5 minutes ago
241
+ }),
242
+ createMockChannel({
243
+ id: 'channel-3',
244
+ participantName: 'Carol Williams',
245
+ participantId: 'participant-3',
246
+ participantImage: 'https://i.pravatar.cc/150?img=4',
247
+ lastMessageText: 'See you tomorrow',
248
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 60), // 1 hour ago
249
+ unreadCount: 15,
250
+ }),
251
+ createMockChannel({
252
+ id: 'channel-4',
253
+ participantName: 'David Brown',
254
+ participantId: 'participant-4',
255
+ lastMessageText: 'Thanks!',
256
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 60 * 2), // 2 hours ago
257
+ }),
258
+ ]
259
+
260
+ const selectedChannel = channels.find(c => c.id === selectedChannelId) || null
261
+
262
+ return (
263
+ <div className="w-[360px] bg-chalk border border-sand rounded-lg overflow-hidden">
264
+ {channels.map(channel => (
265
+ <CustomChannelPreview
266
+ key={channel.id}
267
+ channel={channel}
268
+ selectedChannel={selectedChannel}
269
+ onChannelSelect={(channel) => {
270
+ console.log('Channel selected:', channel.id)
271
+ }}
272
+ />
273
+ ))}
274
+ </div>
275
+ )
276
+ }
277
+
278
+ export const WithUrl: StoryFn<ComponentProps> = Template.bind({})
279
+ WithUrl.args = {
280
+ channel: createMockChannel({
281
+ id: 'channel-url',
282
+ participantName: 'Ivan Rodriguez',
283
+ participantId: 'participant-url',
284
+ participantImage: 'https://i.pravatar.cc/150?img=10',
285
+ lastMessageText: 'https://example.com/page',
286
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 8), // 8 minutes ago
287
+ }),
288
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
289
+ }
290
+
291
+ export const WithVeryLongUrl: StoryFn<ComponentProps> = Template.bind({})
292
+ WithVeryLongUrl.args = {
293
+ channel: createMockChannel({
294
+ id: 'channel-long-url',
295
+ participantName: 'Julia Martinez',
296
+ participantId: 'participant-long-url',
297
+ participantImage: 'https://i.pravatar.cc/150?img=11',
298
+ lastMessageText: 'https://example.com/very/long/path/with/many/segments/and/query/parameters?param1=value1&param2=value2&param3=value3&param4=value4&param5=very-long-value-that-makes-the-url-extremely-long',
299
+ lastMessageTime: new Date(Date.now() - 1000 * 60 * 20), // 20 minutes ago
300
+ }),
301
+ onChannelSelect: (channel) => console.log('Channel selected:', channel.id),
302
+ }
303
+
@@ -0,0 +1,114 @@
1
+ import React from 'react';
2
+ import { ChannelPreviewUIComponentProps } from 'stream-chat-react';
3
+ import { Channel } from 'stream-chat';
4
+ import classNames from 'classnames';
5
+ import { Avatar } from '../Avatar';
6
+
7
+ /**
8
+ * Custom channel preview that handles selection
9
+ */
10
+ const CustomChannelPreview: React.FC<
11
+ ChannelPreviewUIComponentProps & {
12
+ selectedChannel?: Channel;
13
+ onChannelSelect: (channel: Channel) => void;
14
+ debug?: boolean;
15
+ }
16
+ > = ({ channel, selectedChannel, onChannelSelect, debug = false, ...props }) => {
17
+ const isSelected = selectedChannel?.id === channel?.id;
18
+
19
+ const handleClick = () => {
20
+ if (channel) {
21
+ onChannelSelect(channel);
22
+ }
23
+ };
24
+
25
+ // Get participant info
26
+ const members = Object.values(channel?.state?.members || {});
27
+ const participant = members.find(member =>
28
+ member.user?.id && member.user.id !== channel?._client?.userID
29
+ );
30
+ const participantName = participant?.user?.name || 'Conversation';
31
+ const participantImage = participant?.user?.image;
32
+ const participantInitial = participantName.charAt(0).toUpperCase();
33
+
34
+ // Get last message and format timestamp
35
+ const lastMessage = channel?.state?.messages?.[channel.state.messages.length - 1];
36
+ const lastMessageText = lastMessage?.text || 'No messages yet';
37
+ const lastMessageTime = lastMessage?.created_at
38
+ ? new Date(lastMessage.created_at).toLocaleTimeString([], {
39
+ hour: '2-digit',
40
+ minute: '2-digit',
41
+ })
42
+ : '';
43
+
44
+ // Get unread count from channel state
45
+ const unread = channel?.state?.unreadCount || 0;
46
+
47
+ if (debug) {
48
+ console.log('📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER', {
49
+ channelId: channel?.id,
50
+ isSelected,
51
+ participantName,
52
+ unread,
53
+ hasTimestamp: !!lastMessageTime,
54
+ });
55
+ }
56
+
57
+ return (
58
+ <button
59
+ type="button"
60
+ onClick={handleClick}
61
+ className={classNames(
62
+ 'w-full px-4 py-3 transition-colors border-b border-sand text-left max-w-full overflow-hidden focus-ring',
63
+ {
64
+ 'bg-primary-alt bg-opacity-10 border-l-4 border-l-primary': isSelected,
65
+ 'hover:bg-sand': !isSelected,
66
+ }
67
+ )}
68
+ >
69
+ <div className="flex items-start gap-3">
70
+ {/* Avatar */}
71
+ <Avatar
72
+ id={participant?.user?.id || channel.id || 'unknown'}
73
+ name={participantName}
74
+ image={participantImage}
75
+ size={44}
76
+ />
77
+
78
+ {/* Content column */}
79
+ <div className="flex-1 min-w-0 flex flex-col gap-1">
80
+ {/* Name and timestamp row */}
81
+ <div className="flex items-center justify-between gap-2">
82
+ <h3
83
+ className={classNames(
84
+ 'text-sm font-medium truncate',
85
+ isSelected ? 'text-primary' : 'text-charcoal'
86
+ )}
87
+ >
88
+ {participantName}
89
+ </h3>
90
+ {lastMessageTime && (
91
+ <span className="text-xs text-stone flex-shrink-0">
92
+ {lastMessageTime}
93
+ </span>
94
+ )}
95
+ </div>
96
+
97
+ {/* Message and unread badge row */}
98
+ <div className="flex items-center justify-between gap-2 min-w-0">
99
+ <p className="text-xs text-stone mr-2 flex-1 line-clamp-2">
100
+ {lastMessageText}
101
+ </p>
102
+ {unread > 0 && (
103
+ <span className="bg-primary text-white text-xs px-2 py-0.5 rounded-full min-w-[20px] text-center flex-shrink-0">
104
+ {unread > 99 ? '99+' : unread}
105
+ </span>
106
+ )}
107
+ </div>
108
+ </div>
109
+ </div>
110
+ </button>
111
+ );
112
+ };
113
+
114
+ export default CustomChannelPreview;
@@ -0,0 +1,129 @@
1
+ import React from 'react';
2
+ import { NotePencilIcon } from "@phosphor-icons/react/dist/csr/NotePencil";
3
+
4
+ import classNames from 'classnames';
5
+ import { ChannelList as StreamChannelList, useChatContext } from 'stream-chat-react';
6
+ import type { ChannelListProps } from '../../types';
7
+ import { IconButton } from '../IconButton';
8
+ import { useMessagingContext } from '../../providers/MessagingProvider';
9
+ import CustomChannelPreview from './CustomChannelPreview';
10
+
11
+
12
+ /**
13
+ * Channel list component with customizable header and actions
14
+ */
15
+ export const ChannelList: React.FC<ChannelListProps> = ({
16
+ onChannelSelect,
17
+ selectedChannel,
18
+ showStartConversation = false,
19
+ onStartConversation,
20
+ participantLabel = 'participants',
21
+ className,
22
+ }) => {
23
+ // Track renders
24
+ const renderCountRef = React.useRef(0);
25
+ renderCountRef.current++;
26
+
27
+ // Get debug flag from context
28
+ const { debug = false } = useMessagingContext();
29
+
30
+ if (debug) {
31
+ console.log('📺 [ChannelList] 🔄 RENDER START', {
32
+ renderCount: renderCountRef.current,
33
+ selectedChannelId: selectedChannel?.id,
34
+ showStartConversation,
35
+ participantLabel
36
+ });
37
+ }
38
+
39
+ const { client } = useChatContext();
40
+
41
+ if (debug) {
42
+ console.log('📺 [ChannelList] 📡 CHAT CONTEXT', {
43
+ renderCount: renderCountRef.current,
44
+ hasClient: !!client,
45
+ clientUserId: client?.userID,
46
+ clientConnected: client?.wsConnection?.isHealthy
47
+ });
48
+ }
49
+
50
+ // Filter for messaging channels
51
+ const filters = React.useMemo(() => {
52
+ const userId = client.userID;
53
+ const newFilters = userId
54
+ ? { type: 'messaging', members: { $in: [userId] }, hidden: false }
55
+ : { type: 'messaging' };
56
+
57
+ if (debug) {
58
+ console.log('📺 [ChannelList] 🔍 FILTERS MEMOIZED', {
59
+ renderCount: renderCountRef.current,
60
+ userId,
61
+ filters: newFilters
62
+ });
63
+ }
64
+
65
+ return newFilters;
66
+ }, [client.userID, debug]);
67
+
68
+ return (
69
+ <div className={classNames('h-full flex flex-col min-w-0 overflow-hidden', className)}>
70
+ {/* Header */}
71
+ <div className="px-4 py-4 border-b border-sand bg-chalk">
72
+ <div className="flex items-center justify-between gap-3 min-h-10 min-w-0">
73
+ <h2 className="text-lg font-semibold text-charcoal">Conversations</h2>
74
+ <div className="flex items-center gap-2">
75
+ {showStartConversation && onStartConversation && (
76
+ <IconButton
77
+ label="Start a new conversation"
78
+ onClick={onStartConversation}
79
+ className="inline-flex size-10 items-center justify-center"
80
+ >
81
+ <NotePencilIcon className="h-5 w-5" />
82
+ </IconButton>
83
+ )}
84
+ </div>
85
+ </div>
86
+ </div>
87
+
88
+ {/* Channel List */}
89
+ <div className="flex-1 overflow-hidden min-w-0">
90
+ {(() => {
91
+ if (debug) {
92
+ console.log('📺 [ChannelList] 🎬 RENDERING STREAM CHANNEL LIST', {
93
+ renderCount: renderCountRef.current,
94
+ filters,
95
+ hasClient: !!client,
96
+ clientUserId: client?.userID
97
+ });
98
+ }
99
+
100
+ return (
101
+ <StreamChannelList
102
+ filters={filters}
103
+ sort={{ last_message_at: -1 }}
104
+ options={{ limit: 30 }}
105
+ Preview={(props) => {
106
+ if (debug) {
107
+ console.log('📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER', {
108
+ channelId: props.channel?.id,
109
+ selectedChannelId: selectedChannel?.id,
110
+ isSelected: selectedChannel?.id === props.channel?.id
111
+ });
112
+ }
113
+
114
+ return (
115
+ <CustomChannelPreview
116
+ {...props}
117
+ selectedChannel={selectedChannel}
118
+ onChannelSelect={onChannelSelect}
119
+ debug={debug}
120
+ />
121
+ );
122
+ }}
123
+ />
124
+ );
125
+ })()}
126
+ </div>
127
+ </div>
128
+ );
129
+ };