@linktr.ee/messaging-react 1.0.2 → 1.1.0-rc-1760927977

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 (39) hide show
  1. package/dist/assets/index.css +1 -0
  2. package/dist/index.d.ts +2 -1
  3. package/dist/index.js +836 -1079
  4. package/dist/index.js.map +1 -1
  5. package/package.json +4 -3
  6. package/src/components/ActionButton/ActionButton.stories.tsx +2 -1
  7. package/src/components/ActionButton/ActionButton.test.tsx +2 -0
  8. package/src/components/Avatar/Avatar.stories.tsx +2 -1
  9. package/src/components/Avatar/index.tsx +2 -1
  10. package/src/components/ChannelList/ChannelList.stories.tsx +4 -2
  11. package/src/components/ChannelList/CustomChannelPreview.stories.tsx +31 -27
  12. package/src/components/ChannelList/CustomChannelPreview.tsx +5 -11
  13. package/src/components/ChannelList/index.tsx +43 -35
  14. package/src/components/ChannelView.tsx +150 -127
  15. package/src/components/CloseButton/index.tsx +4 -5
  16. package/src/components/IconButton/IconButton.stories.tsx +3 -3
  17. package/src/components/Loading/Loading.stories.tsx +2 -1
  18. package/src/components/Loading/index.tsx +7 -9
  19. package/src/components/MessagingShell/EmptyState.stories.tsx +2 -1
  20. package/src/components/MessagingShell/ErrorState.stories.tsx +2 -1
  21. package/src/components/MessagingShell/LoadingState.stories.tsx +2 -1
  22. package/src/components/MessagingShell/LoadingState.tsx +3 -5
  23. package/src/components/MessagingShell/index.tsx +159 -135
  24. package/src/components/ParticipantPicker/ParticipantItem.stories.tsx +4 -2
  25. package/src/components/ParticipantPicker/ParticipantItem.tsx +25 -21
  26. package/src/components/ParticipantPicker/ParticipantPicker.stories.tsx +4 -2
  27. package/src/components/ParticipantPicker/ParticipantPicker.tsx +104 -76
  28. package/src/components/ParticipantPicker/index.tsx +93 -72
  29. package/src/components/SearchInput/SearchInput.stories.tsx +2 -1
  30. package/src/components/SearchInput/SearchInput.test.tsx +4 -2
  31. package/src/components/SearchInput/index.tsx +14 -15
  32. package/src/hooks/useParticipants.ts +1 -0
  33. package/src/index.ts +3 -0
  34. package/src/providers/MessagingProvider.tsx +213 -135
  35. package/src/stories/mocks.tsx +18 -19
  36. package/src/styles.css +75 -0
  37. package/src/test/setup.ts +11 -12
  38. package/src/test/utils.tsx +6 -7
  39. package/src/types.ts +1 -1
@@ -1,65 +1,68 @@
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';
1
+ import classNames from 'classnames'
2
+ import React, { useState, useCallback, useRef, useEffect } from 'react'
3
+ import type { Channel } from 'stream-chat'
4
+
5
+ import { useMessaging } from '../../hooks/useMessaging'
6
+ import type { MessagingShellProps, Participant } from '../../types'
7
+ import { ChannelList } from '../ChannelList'
8
+ import { ChannelView } from '../ChannelView'
9
+ import { ParticipantPicker } from '../ParticipantPicker'
10
+
11
+ import { EmptyState } from './EmptyState'
12
+ import { ErrorState } from './ErrorState'
13
+ import { LoadingState } from './LoadingState'
12
14
 
13
15
  /**
14
16
  * Main messaging interface component that combines channel list and channel view
15
17
  */
16
18
  export const MessagingShell: React.FC<MessagingShellProps> = ({
17
19
  capabilities = {},
18
- customization = {},
19
20
  className,
20
21
  renderMessageInputActions,
21
22
  onChannelSelect,
22
23
  onParticipantSelect,
23
24
  }) => {
24
- const {
25
- service,
26
- client,
27
- isConnected,
28
- isLoading,
29
- error,
25
+ const {
26
+ service,
27
+ client,
28
+ isConnected,
29
+ isLoading,
30
+ error,
30
31
  refreshConnection,
31
32
  debug,
32
- } = useMessaging();
33
+ } = useMessaging()
34
+
35
+ const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null)
36
+ const [hasChannels, setHasChannels] = useState(false)
37
+ const [_showParticipantPicker, setShowParticipantPicker] = useState(false)
38
+ const [existingParticipantIds, setExistingParticipantIds] = useState<
39
+ Set<string>
40
+ >(new Set())
41
+ const [pickerKey, setPickerKey] = useState(0) // Key to force remount of ParticipantPicker
33
42
 
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
43
+ const participantPickerRef = useRef<HTMLDialogElement>(null)
39
44
 
40
- const participantPickerRef = useRef<HTMLDialogElement>(null);
41
-
42
45
  const {
43
46
  showStartConversation = false,
44
47
  participantSource,
45
48
  participantLabel = 'participants',
46
- } = capabilities;
49
+ } = capabilities
47
50
 
48
51
  // Track if we've already synced channels to prevent repeated API calls
49
- const syncedRef = useRef<string | null>(null);
50
-
52
+ const syncedRef = useRef<string | null>(null)
53
+
51
54
  // Function to sync channels (extracted for reuse)
52
55
  const syncChannels = useCallback(async () => {
53
- if (!client || !isConnected) return;
54
-
55
- const userId = client.userID;
56
- if (!userId) return;
56
+ if (!client || !isConnected) return
57
+
58
+ const userId = client.userID
59
+ if (!userId) return
57
60
 
58
61
  try {
59
62
  if (debug) {
60
- console.log('[MessagingShell] Syncing channels for user:', userId);
63
+ console.log('[MessagingShell] Syncing channels for user:', userId)
61
64
  }
62
-
65
+
63
66
  const channels = await client.queryChannels(
64
67
  {
65
68
  type: 'messaging',
@@ -67,125 +70,140 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
67
70
  },
68
71
  {},
69
72
  { limit: 100 }
70
- );
73
+ )
71
74
 
72
- const memberIds = new Set<string>();
75
+ const memberIds = new Set<string>()
73
76
  channels.forEach((channel: Channel) => {
74
- const members = channel.state.members || {};
75
- Object.values(members).forEach((member: any) => {
76
- const memberId = member.user?.id;
77
+ const members = channel.state.members
78
+ Object.values(members).forEach((member) => {
79
+ const memberId = member.user?.id
77
80
  if (memberId && memberId !== userId) {
78
- memberIds.add(memberId);
81
+ memberIds.add(memberId)
79
82
  }
80
- });
81
- });
83
+ })
84
+ })
85
+
86
+ setExistingParticipantIds(memberIds)
87
+ setHasChannels(channels.length > 0)
88
+ syncedRef.current = userId // Mark as synced for this user
82
89
 
83
- setExistingParticipantIds(memberIds);
84
- setHasChannels(channels.length > 0);
85
- syncedRef.current = userId; // Mark as synced for this user
86
-
87
90
  if (debug) {
88
91
  console.log('[MessagingShell] Channels synced successfully:', {
89
92
  channelCount: channels.length,
90
- memberCount: memberIds.size
91
- });
93
+ memberCount: memberIds.size,
94
+ })
92
95
  }
93
96
  } catch (error) {
94
- console.error('[MessagingShell] Failed to sync channels:', error);
97
+ console.error('[MessagingShell] Failed to sync channels:', error)
95
98
  // Don't mark as synced on error, allow retry
96
99
  }
97
- }, [client, isConnected, debug]);
98
-
100
+ }, [client, isConnected, debug])
101
+
99
102
  // Sync existing channels to track which participants we can already message
100
103
  useEffect(() => {
101
- if (!client || !isConnected) return;
102
-
103
- const userId = client.userID;
104
- if (!userId) return;
105
-
104
+ if (!client || !isConnected) return
105
+
106
+ const userId = client.userID
107
+ if (!userId) return
108
+
106
109
  // Prevent repeated sync for the same user
107
- if (syncedRef.current === userId) return;
110
+ if (syncedRef.current === userId) return
108
111
 
109
- syncChannels();
110
- }, [client, isConnected, syncChannels]);
112
+ syncChannels()
113
+ }, [client, isConnected, syncChannels])
111
114
 
112
- const handleChannelSelect = useCallback((channel: Channel) => {
113
- setSelectedChannel(channel);
114
- onChannelSelect?.(channel);
115
- }, [onChannelSelect]);
115
+ const handleChannelSelect = useCallback(
116
+ (channel: Channel) => {
117
+ setSelectedChannel(channel)
118
+ onChannelSelect?.(channel)
119
+ },
120
+ [onChannelSelect]
121
+ )
116
122
 
117
123
  const handleBackToChannelList = useCallback(() => {
118
- setSelectedChannel(null);
119
- }, []);
124
+ setSelectedChannel(null)
125
+ }, [])
120
126
 
121
127
  const handleStartConversation = useCallback(() => {
122
128
  if (participantSource) {
123
- setPickerKey(prev => prev + 1); // Increment key to force remount
124
- setShowParticipantPicker(true);
125
- participantPickerRef.current?.showModal();
129
+ setPickerKey((prev) => prev + 1) // Increment key to force remount
130
+ setShowParticipantPicker(true)
131
+ participantPickerRef.current?.showModal()
126
132
  }
127
- }, [participantSource]);
133
+ }, [participantSource])
128
134
 
129
- const handleSelectParticipant = useCallback(async (participant: Participant) => {
130
- if (!service) return;
135
+ const handleSelectParticipant = useCallback(
136
+ async (participant: Participant) => {
137
+ if (!service) return
131
138
 
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
139
  try {
146
- await channel.show();
140
+ if (debug) {
141
+ console.log(
142
+ '[MessagingShell] Starting conversation with:',
143
+ participant.id
144
+ )
145
+ }
146
+
147
+ const channel = await service.startChannelWithFollower({
148
+ id: participant.id,
149
+ name: participant.name,
150
+ email: participant.email,
151
+ phone: participant.phone,
152
+ })
153
+
154
+ // Show the channel
155
+ try {
156
+ await channel.show()
157
+ } catch (error) {
158
+ console.warn('[MessagingShell] Failed to unhide channel:', error)
159
+ }
160
+
161
+ setSelectedChannel(channel)
162
+ setShowParticipantPicker(false)
163
+ participantPickerRef.current?.close()
164
+
165
+ onParticipantSelect?.(participant)
147
166
  } catch (error) {
148
- console.warn('[MessagingShell] Failed to unhide channel:', error);
167
+ console.error('[MessagingShell] Failed to start conversation:', error)
149
168
  }
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]);
169
+ },
170
+ [service, onParticipantSelect, debug]
171
+ )
160
172
 
161
173
  const handleCloseParticipantPicker = useCallback(() => {
162
- setShowParticipantPicker(false);
163
- participantPickerRef.current?.close();
164
- }, []);
174
+ setShowParticipantPicker(false)
175
+ participantPickerRef.current?.close()
176
+ }, [])
165
177
 
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]);
178
+ const handleLeaveConversation = useCallback(
179
+ async (channel: Channel) => {
180
+ if (debug) {
181
+ console.log('[MessagingShell] Leaving conversation:', channel.id)
182
+ }
183
+ setSelectedChannel(null)
184
+
185
+ // Force re-sync to update the existing participants list
186
+ syncedRef.current = null
187
+ await syncChannels()
188
+ },
189
+ [syncChannels, debug]
190
+ )
187
191
 
188
- const isChannelSelected = Boolean(selectedChannel);
192
+ const handleBlockParticipant = useCallback(
193
+ async (participantId?: string) => {
194
+ if (debug) {
195
+ console.log('[MessagingShell] Blocking participant:', participantId)
196
+ }
197
+ setSelectedChannel(null)
198
+
199
+ // Force re-sync to update the existing participants list
200
+ syncedRef.current = null
201
+ await syncChannels()
202
+ },
203
+ [syncChannels, debug]
204
+ )
205
+
206
+ const isChannelSelected = Boolean(selectedChannel)
189
207
 
190
208
  // Show loading state
191
209
  if (isLoading) {
@@ -193,7 +211,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
193
211
  <div className={classNames('h-full', className)}>
194
212
  <LoadingState />
195
213
  </div>
196
- );
214
+ )
197
215
  }
198
216
 
199
217
  // Show error state
@@ -202,19 +220,19 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
202
220
  <div className={classNames('h-full', className)}>
203
221
  <ErrorState error={error} onRetry={refreshConnection} />
204
222
  </div>
205
- );
223
+ )
206
224
  }
207
225
 
208
226
  // Show not connected state
209
227
  if (!isConnected || !client) {
210
228
  return (
211
229
  <div className={classNames('h-full', className)}>
212
- <ErrorState
213
- error="Not connected to messaging service"
214
- onRetry={refreshConnection}
230
+ <ErrorState
231
+ error="Not connected to messaging service"
232
+ onRetry={refreshConnection}
215
233
  />
216
234
  </div>
217
- );
235
+ )
218
236
  }
219
237
 
220
238
  return (
@@ -225,7 +243,8 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
225
243
  className={classNames(
226
244
  'min-h-0 min-w-0 bg-white lg:bg-chalk lg:flex lg:flex-col lg:border-r lg:border-sand',
227
245
  {
228
- 'hidden lg:flex lg:w-80 lg:min-w-[280px] lg:max-w-[360px]': isChannelSelected,
246
+ 'hidden lg:flex lg:w-80 lg:min-w-[280px] lg:max-w-[360px]':
247
+ isChannelSelected,
229
248
  'flex flex-col w-full lg:flex-1 lg:max-w-2xl': !isChannelSelected,
230
249
  }
231
250
  )}
@@ -233,7 +252,9 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
233
252
  <ChannelList
234
253
  onChannelSelect={handleChannelSelect}
235
254
  selectedChannel={selectedChannel || undefined}
236
- showStartConversation={showStartConversation && Boolean(participantSource)}
255
+ showStartConversation={
256
+ showStartConversation && Boolean(participantSource)
257
+ }
237
258
  onStartConversation={handleStartConversation}
238
259
  participantLabel={participantLabel}
239
260
  />
@@ -243,7 +264,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
243
264
  <div
244
265
  className={classNames('flex-1 flex-col min-w-0 min-h-0', {
245
266
  'hidden lg:flex': !isChannelSelected,
246
- 'flex': isChannelSelected,
267
+ flex: isChannelSelected,
247
268
  })}
248
269
  >
249
270
  {selectedChannel ? (
@@ -261,7 +282,9 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
261
282
  ) : (
262
283
  <EmptyState
263
284
  hasChannels={hasChannels}
264
- onStartConversation={showStartConversation ? handleStartConversation : undefined}
285
+ onStartConversation={
286
+ showStartConversation ? handleStartConversation : undefined
287
+ }
265
288
  participantLabel={participantLabel}
266
289
  />
267
290
  )}
@@ -270,12 +293,13 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
270
293
 
271
294
  {/* Participant Picker Dialog */}
272
295
  {participantSource && (
296
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
273
297
  <dialog
274
298
  ref={participantPickerRef}
275
299
  className="mes-dialog"
276
300
  onClick={(e) => {
277
301
  if (e.target === participantPickerRef.current) {
278
- handleCloseParticipantPicker();
302
+ handleCloseParticipantPicker()
279
303
  }
280
304
  }}
281
305
  onClose={handleCloseParticipantPicker}
@@ -294,5 +318,5 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
294
318
  </dialog>
295
319
  )}
296
320
  </div>
297
- );
298
- };
321
+ )
322
+ }
@@ -1,8 +1,10 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react';
2
- import { ParticipantItem } from './ParticipantItem';
3
- import type { Participant } from '../../types';
4
2
  import React from 'react';
5
3
 
4
+ import type { Participant } from '../../types';
5
+
6
+ import { ParticipantItem } from './ParticipantItem';
7
+
6
8
  const meta: Meta<typeof ParticipantItem> = {
7
9
  title: 'ParticipantItem',
8
10
  component: ParticipantItem,
@@ -1,21 +1,27 @@
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';
1
+ import { ChatCircleDotsIcon, SpinnerGapIcon } from '@phosphor-icons/react'
2
+ import React from 'react'
6
3
 
4
+ import type { Participant } from '../../types'
5
+ import { Avatar } from '../Avatar'
7
6
 
8
7
  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
- }
8
+ participant: Participant
9
+ handleSelectParticipant: (participant: Participant) => void
10
+ handleKeyDown: (event: React.KeyboardEvent, participant: Participant) => void
11
+ displayName: string
12
+ displaySecondary?: string
13
+ startingChatWithId?: string | null
14
+ }
16
15
 
17
- export const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant, handleSelectParticipant, handleKeyDown, displayName, displaySecondary, startingChatWithId }) => (
18
- <li key={participant.id}>
16
+ export const ParticipantItem: React.FC<ParticipantItemProps> = ({
17
+ participant,
18
+ handleSelectParticipant,
19
+ handleKeyDown,
20
+ displayName,
21
+ displaySecondary,
22
+ startingChatWithId,
23
+ }) => (
24
+ <li key={participant.id}>
19
25
  <button
20
26
  type="button"
21
27
  onClick={() => handleSelectParticipant(participant)}
@@ -31,20 +37,18 @@ export const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant, h
31
37
  image={participant.image}
32
38
  size={40}
33
39
  />
34
-
40
+
35
41
  {/* Info */}
36
42
  <div className="flex-1 min-w-0">
37
43
  <h4 className="text-sm font-medium text-charcoal truncate">
38
44
  {displayName}
39
45
  </h4>
40
46
  {displaySecondary && (
41
- <p className="text-xs text-stone truncate">
42
- {displaySecondary}
43
- </p>
47
+ <p className="text-xs text-stone truncate">{displaySecondary}</p>
44
48
  )}
45
49
  </div>
46
50
  </div>
47
-
51
+
48
52
  {/* Icon */}
49
53
  <div className="flex-shrink-0">
50
54
  {startingChatWithId === participant.id ? (
@@ -52,8 +56,8 @@ export const ParticipantItem: React.FC<ParticipantItemProps> = ({ participant, h
52
56
  ) : (
53
57
  <ChatCircleDotsIcon className="h-5 w-5 text-stone" />
54
58
  )}
55
- </div>
59
+ </div>
56
60
  </div>
57
61
  </button>
58
62
  </li>
59
- )
63
+ )
@@ -1,8 +1,10 @@
1
1
  import type { Meta, StoryFn } from '@storybook/react'
2
- import { ParticipantPicker } from './index'
3
- import { mockParticipantSource } from '../../stories/mocks'
4
2
  import React from 'react'
5
3
 
4
+ import { mockParticipantSource } from '../../stories/mocks'
5
+
6
+ import { ParticipantPicker } from './index'
7
+
6
8
  type ComponentProps = React.ComponentProps<typeof ParticipantPicker>
7
9
 
8
10
  const meta: Meta<ComponentProps> = {