@linktr.ee/messaging-react 1.3.0 → 1.4.0-rc-1761053580

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.
@@ -8,7 +8,7 @@ export const EmptyState: React.FC<{
8
8
  onStartConversation?: () => void
9
9
  participantLabel: string
10
10
  }> = ({ hasChannels, onStartConversation, participantLabel }) => (
11
- <div className="flex items-center justify-center h-full p-8 text-balance">
11
+ <div className="messaging-empty-state flex items-center justify-center h-full p-8 text-balance">
12
12
  <div className="text-center max-w-sm">
13
13
  <div className="w-24 h-24 bg-primary-alt/10 rounded-full flex items-center justify-center mx-auto mb-6">
14
14
  <span className="text-4xl">💬</span>
@@ -25,19 +25,19 @@ const Template: StoryFn<ComponentProps> = (args) => {
25
25
 
26
26
  export const ConnectionError: StoryFn<ComponentProps> = Template.bind({})
27
27
  ConnectionError.args = {
28
- error: 'Unable to connect to messaging service. Please check your connection and try again.',
29
- onRetry: () => console.log('Retry clicked'),
28
+ message:
29
+ 'Unable to connect to messaging service. Please check your connection and try again.',
30
+ onBack: () => console.log('Go Back clicked'),
30
31
  }
31
32
 
32
- export const NoRetry: StoryFn<ComponentProps> = Template.bind({})
33
- NoRetry.args = {
34
- error: 'Failed to load messages.',
35
- onRetry: undefined,
33
+ export const NoBackButton: StoryFn<ComponentProps> = Template.bind({})
34
+ NoBackButton.args = {
35
+ message: 'Failed to load messages.',
36
+ onBack: undefined,
36
37
  }
37
38
 
38
39
  export const CustomError: StoryFn<ComponentProps> = Template.bind({})
39
40
  CustomError.args = {
40
- error: 'Your session has expired. Please refresh the page to continue.',
41
- onRetry: () => console.log('Retry clicked'),
41
+ message: 'Your session has expired. Please refresh the page to continue.',
42
+ onBack: () => console.log('Go Back clicked'),
42
43
  }
43
-
@@ -1,33 +1,31 @@
1
- import React from 'react';
1
+ import React from 'react'
2
2
 
3
3
  /**
4
- * Error state component
4
+ * Error state component shown when something goes wrong
5
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">
6
+ export const ErrorState: React.FC<{
7
+ message: string
8
+ onBack?: () => void
9
+ }> = ({ message, onBack }) => (
10
+ <div className="messaging-error-state flex items-center justify-center h-full p-8">
11
+ <div className="text-center max-w-sm">
12
+ <div className="w-24 h-24 bg-danger-alt/20 rounded-full flex items-center justify-center mx-auto mb-6">
10
13
  <span className="text-4xl">⚠️</span>
11
14
  </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
15
 
21
- {onRetry && (
16
+ <h2 className="font-semibold text-charcoal mb-2">Oops!</h2>
17
+
18
+ <p className="text-stone text-sm mb-6">{message}</p>
19
+
20
+ {onBack && (
22
21
  <button
23
22
  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"
23
+ onClick={onBack}
24
+ className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-primary hover:bg-primary-alt rounded-lg focus:outline-none focus:ring-2 focus:ring-primary transition-colors"
26
25
  >
27
- Try Again
26
+ Go Back
28
27
  </button>
29
28
  )}
30
29
  </div>
31
30
  </div>
32
- );
33
-
31
+ )
@@ -4,7 +4,7 @@ import Loading from '../Loading'
4
4
  * Loading state component
5
5
  */
6
6
  export const LoadingState = () => (
7
- <div className="flex items-center justify-center h-full">
7
+ <div className="messaging-loading-state flex items-center justify-center h-full">
8
8
  <div className="flex items-center">
9
9
  <Loading className="w-6 h-6" />
10
10
  <span className="text-sm text-stone">Loading messages</span>
@@ -21,6 +21,8 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
21
21
  renderMessageInputActions,
22
22
  onChannelSelect,
23
23
  onParticipantSelect,
24
+ initialParticipantFilter,
25
+ initialParticipantData,
24
26
  }) => {
25
27
  const {
26
28
  service,
@@ -39,6 +41,10 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
39
41
  Set<string>
40
42
  >(new Set())
41
43
  const [pickerKey, setPickerKey] = useState(0) // Key to force remount of ParticipantPicker
44
+ const [directConversationMode, setDirectConversationMode] = useState(false)
45
+ const [directConversationError, setDirectConversationError] = useState<
46
+ string | null
47
+ >(null)
42
48
 
43
49
  const participantPickerRef = useRef<HTMLDialogElement>(null)
44
50
 
@@ -112,6 +118,111 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
112
118
  syncChannels()
113
119
  }, [client, isConnected, syncChannels])
114
120
 
121
+ // Load initial channel for direct conversation mode
122
+ useEffect(() => {
123
+ if (!initialParticipantFilter || !client || !isConnected) return
124
+
125
+ const loadInitialChannel = async () => {
126
+ const userId = client.userID
127
+ if (!userId) return
128
+
129
+ try {
130
+ if (debug) {
131
+ console.log(
132
+ '[MessagingShell] Loading initial conversation with:',
133
+ initialParticipantFilter
134
+ )
135
+ }
136
+
137
+ const channels = await client.queryChannels(
138
+ {
139
+ type: 'messaging',
140
+ members: { $eq: [userId, initialParticipantFilter] },
141
+ },
142
+ {},
143
+ { limit: 1 }
144
+ )
145
+
146
+ if (channels.length > 0) {
147
+ setSelectedChannel(channels[0])
148
+ setDirectConversationMode(true)
149
+ setDirectConversationError(null)
150
+
151
+ if (debug) {
152
+ console.log(
153
+ '[MessagingShell] Initial conversation loaded:',
154
+ channels[0].id
155
+ )
156
+ }
157
+ } else {
158
+ // No channel found - try to create one if participant data is provided
159
+ if (initialParticipantData && service) {
160
+ if (debug) {
161
+ console.log(
162
+ '[MessagingShell] No conversation found, creating one for:',
163
+ initialParticipantData
164
+ )
165
+ }
166
+
167
+ try {
168
+ // Use the existing service method to create the channel
169
+ const channel = await service.startChannelWithParticipant({
170
+ id: initialParticipantData.id,
171
+ name: initialParticipantData.name,
172
+ email: initialParticipantData.email,
173
+ phone: initialParticipantData.phone,
174
+ })
175
+
176
+ setSelectedChannel(channel)
177
+ setDirectConversationMode(true)
178
+ setDirectConversationError(null)
179
+
180
+ if (debug) {
181
+ console.log(
182
+ '[MessagingShell] Channel created and loaded:',
183
+ channel.id
184
+ )
185
+ }
186
+ } catch (createErr) {
187
+ console.error(
188
+ '[MessagingShell] Failed to create conversation:',
189
+ createErr
190
+ )
191
+ setDirectConversationError('Failed to create conversation')
192
+ }
193
+ } else {
194
+ // No participant data provided, show error
195
+ setDirectConversationError(
196
+ 'No conversation found with this account'
197
+ )
198
+
199
+ if (debug) {
200
+ console.log(
201
+ '[MessagingShell] No conversation found for:',
202
+ initialParticipantFilter
203
+ )
204
+ }
205
+ }
206
+ }
207
+ } catch (err) {
208
+ console.error(
209
+ '[MessagingShell] Failed to load initial conversation:',
210
+ err
211
+ )
212
+ setDirectConversationError('Failed to load conversation')
213
+ }
214
+ }
215
+
216
+ loadInitialChannel()
217
+ }, [
218
+ initialParticipantFilter,
219
+ initialParticipantData,
220
+ client,
221
+ isConnected,
222
+ service,
223
+ debug,
224
+ ])
225
+
115
226
  const handleChannelSelect = useCallback(
116
227
  (channel: Channel) => {
117
228
  setSelectedChannel(channel)
@@ -121,8 +232,12 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
121
232
  )
122
233
 
123
234
  const handleBackToChannelList = useCallback(() => {
235
+ // In direct conversation mode, don't allow going back to channel list
236
+ // The parent component should handle navigation
237
+ if (directConversationMode) return
238
+
124
239
  setSelectedChannel(null)
125
- }, [])
240
+ }, [directConversationMode])
126
241
 
127
242
  const handleStartConversation = useCallback(() => {
128
243
  if (participantSource) {
@@ -181,6 +296,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
181
296
  console.log('[MessagingShell] Leaving conversation:', channel.id)
182
297
  }
183
298
  setSelectedChannel(null)
299
+ setDirectConversationMode(false) // Exit direct conversation mode
184
300
 
185
301
  // Force re-sync to update the existing participants list
186
302
  syncedRef.current = null
@@ -195,6 +311,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
195
311
  console.log('[MessagingShell] Blocking participant:', participantId)
196
312
  }
197
313
  setSelectedChannel(null)
314
+ setDirectConversationMode(false) // Exit direct conversation mode
198
315
 
199
316
  // Force re-sync to update the existing participants list
200
317
  syncedRef.current = null
@@ -218,7 +335,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
218
335
  if (error) {
219
336
  return (
220
337
  <div className={classNames('h-full', className)}>
221
- <ErrorState error={error} onRetry={refreshConnection} />
338
+ <ErrorState message={error} onBack={refreshConnection} />
222
339
  </div>
223
340
  )
224
341
  }
@@ -228,24 +345,43 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
228
345
  return (
229
346
  <div className={classNames('h-full', className)}>
230
347
  <ErrorState
231
- error="Not connected to messaging service"
232
- onRetry={refreshConnection}
348
+ message="Not connected to messaging service"
349
+ onBack={refreshConnection}
233
350
  />
234
351
  </div>
235
352
  )
236
353
  }
237
354
 
355
+ // Show direct conversation error state
356
+ if (directConversationError) {
357
+ return (
358
+ <div className={classNames('h-full', className)}>
359
+ <ErrorState message={directConversationError} />
360
+ </div>
361
+ )
362
+ }
363
+
238
364
  return (
239
- <div className={classNames('h-full bg-white overflow-hidden', className)}>
365
+ <div
366
+ className={classNames(
367
+ 'messaging-shell h-full bg-white overflow-hidden',
368
+ className
369
+ )}
370
+ >
240
371
  <div className="flex h-full min-h-0">
241
372
  {/* Channel List Sidebar */}
242
373
  <div
243
374
  className={classNames(
244
- 'min-h-0 min-w-0 bg-white lg:bg-chalk lg:flex lg:flex-col lg:border-r lg:border-sand',
375
+ 'messaging-channel-list-sidebar min-h-0 min-w-0 bg-white lg:bg-chalk lg:flex lg:flex-col lg:border-r lg:border-sand',
245
376
  {
377
+ // In direct conversation mode, always hide the channel list
378
+ hidden: directConversationMode,
379
+ // Normal mode: hide on mobile when channel selected, show on desktop
246
380
  'hidden lg:flex lg:w-80 lg:min-w-[280px] lg:max-w-[360px]':
247
- isChannelSelected,
248
- 'flex flex-col w-full lg:flex-1 lg:max-w-2xl': !isChannelSelected,
381
+ !directConversationMode && isChannelSelected,
382
+ // Normal mode: show when no channel selected
383
+ 'flex flex-col w-full lg:flex-1 lg:max-w-2xl':
384
+ !directConversationMode && !isChannelSelected,
249
385
  }
250
386
  )}
251
387
  >
@@ -262,10 +398,15 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
262
398
 
263
399
  {/* Channel View */}
264
400
  <div
265
- className={classNames('flex-1 flex-col min-w-0 min-h-0', {
266
- 'hidden lg:flex': !isChannelSelected,
267
- flex: isChannelSelected,
268
- })}
401
+ className={classNames(
402
+ 'messaging-conversation-view flex-1 flex-col min-w-0 min-h-0',
403
+ {
404
+ // In direct conversation mode, always show (full width)
405
+ flex: directConversationMode || isChannelSelected,
406
+ // Normal mode: hide on mobile when no channel selected
407
+ 'hidden lg:flex': !directConversationMode && !isChannelSelected,
408
+ }
409
+ )}
269
410
  >
270
411
  {selectedChannel ? (
271
412
  <div className="flex-1 min-h-0 flex flex-col">
@@ -273,7 +414,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
273
414
  channel={selectedChannel}
274
415
  key={selectedChannel.id}
275
416
  onBack={handleBackToChannelList}
276
- showBackButton
417
+ showBackButton={!directConversationMode}
277
418
  renderMessageInputActions={renderMessageInputActions}
278
419
  onLeaveConversation={handleLeaveConversation}
279
420
  onBlockParticipant={handleBlockParticipant}
@@ -123,7 +123,12 @@ export const ParticipantPicker: React.FC<ParticipantPickerProps> = ({
123
123
  }
124
124
 
125
125
  return (
126
- <div className={classNames('flex flex-col h-full', className)}>
126
+ <div
127
+ className={classNames(
128
+ 'messaging-participant-picker flex flex-col h-full',
129
+ className
130
+ )}
131
+ >
127
132
  {/* Header */}
128
133
  <div className="px-4 py-4 border-b border-sand bg-chalk">
129
134
  <div className="flex items-center justify-between mb-3">
package/src/types.ts CHANGED
@@ -1,113 +1,135 @@
1
- import type { MessagingUser, StreamChatServiceConfig } from '@linktr.ee/messaging-core';
2
- import type { Channel } from 'stream-chat';
1
+ import type {
2
+ MessagingUser,
3
+ StreamChatServiceConfig,
4
+ } from '@linktr.ee/messaging-core'
5
+ import type { Channel } from 'stream-chat'
3
6
 
4
7
  /**
5
8
  * Generic participant interface for different host environments
6
9
  */
7
10
  export interface Participant {
8
- id: string;
9
- name: string;
10
- email?: string;
11
- image?: string;
12
- username?: string;
13
- phone?: string;
14
- metadata?: Record<string, any>;
11
+ id: string
12
+ name: string
13
+ email?: string
14
+ image?: string
15
+ username?: string
16
+ phone?: string
17
+ metadata?: Record<string, any>
15
18
  }
16
19
 
17
20
  /**
18
21
  * Source for loading participants (followers, team members, etc.)
19
22
  */
20
23
  export interface ParticipantSource {
21
- loadParticipants: (options?: {
22
- search?: string;
23
- limit?: number;
24
- cursor?: string;
24
+ loadParticipants: (options?: {
25
+ search?: string
26
+ limit?: number
27
+ cursor?: string
25
28
  }) => Promise<{
26
- participants: Participant[];
27
- hasMore: boolean;
28
- nextCursor?: string;
29
- }>;
30
- totalCount?: number;
31
- loading?: boolean;
29
+ participants: Participant[]
30
+ hasMore: boolean
31
+ nextCursor?: string
32
+ }>
33
+ totalCount?: number
34
+ loading?: boolean
32
35
  }
33
36
 
34
37
  /**
35
38
  * Messaging capabilities configuration
36
39
  */
37
40
  export interface MessagingCapabilities {
38
- showStartConversation?: boolean;
39
- participantSource?: ParticipantSource;
40
- participantLabel?: string; // e.g., "followers", "team members"
41
+ showStartConversation?: boolean
42
+ participantSource?: ParticipantSource
43
+ participantLabel?: string // e.g., "followers", "team members"
41
44
  }
42
45
 
43
46
  /**
44
47
  * Customization options
45
48
  */
46
49
  export interface MessagingCustomization {
47
- theme?: 'light' | 'dark' | 'auto';
48
- accentColor?: string;
49
- showParticipantStatus?: boolean;
50
+ theme?: 'light' | 'dark' | 'auto'
51
+ accentColor?: string
52
+ showParticipantStatus?: boolean
50
53
  }
51
54
 
52
55
  /**
53
56
  * Main MessagingShell component props
54
57
  */
55
58
  export interface MessagingShellProps {
56
- capabilities?: MessagingCapabilities;
57
- customization?: MessagingCustomization;
58
- className?: string;
59
- renderMessageInputActions?: (channel: Channel) => React.ReactNode;
60
- onChannelSelect?: (channel: Channel) => void;
61
- onParticipantSelect?: (participant: Participant) => void;
59
+ capabilities?: MessagingCapabilities
60
+ customization?: MessagingCustomization
61
+ className?: string
62
+ renderMessageInputActions?: (channel: Channel) => React.ReactNode
63
+ onChannelSelect?: (channel: Channel) => void
64
+ onParticipantSelect?: (participant: Participant) => void
65
+
66
+ /**
67
+ * Auto-select a conversation with this participant on mount.
68
+ * Useful for deep-linking to a specific conversation (e.g., /messages/[accountUuid])
69
+ *
70
+ * If a channel with this participant exists, it will be auto-selected.
71
+ * If no channel exists, you can provide initialParticipantData to automatically
72
+ * create the channel using the existing ChannelCreator.
73
+ */
74
+ initialParticipantFilter?: string
75
+
76
+ /**
77
+ * Participant data for auto-creating a channel when initialParticipantFilter
78
+ * is provided but no channel exists. If this is provided, MessagingShell will
79
+ * automatically call service.startChannelWithParticipant() to create the channel.
80
+ *
81
+ * This reuses the existing ChannelCreator from StreamChatServiceConfig.
82
+ */
83
+ initialParticipantData?: Participant
62
84
  }
63
85
 
64
86
  /**
65
87
  * ChannelList component props
66
88
  */
67
89
  export interface ChannelListProps {
68
- onChannelSelect: (channel: Channel) => void;
69
- selectedChannel?: Channel;
70
- showStartConversation?: boolean;
71
- onStartConversation?: () => void;
72
- participantLabel?: string;
73
- className?: string;
90
+ onChannelSelect: (channel: Channel) => void
91
+ selectedChannel?: Channel
92
+ showStartConversation?: boolean
93
+ onStartConversation?: () => void
94
+ participantLabel?: string
95
+ className?: string
74
96
  }
75
97
 
76
98
  /**
77
99
  * ChannelView component props
78
100
  */
79
101
  export interface ChannelViewProps {
80
- channel: Channel;
81
- onBack?: () => void;
82
- showBackButton?: boolean;
83
- renderMessageInputActions?: (channel: Channel) => React.ReactNode;
84
- onLeaveConversation?: (channel: Channel) => void;
85
- onBlockParticipant?: (participantId?: string) => void;
86
- className?: string;
102
+ channel: Channel
103
+ onBack?: () => void
104
+ showBackButton?: boolean
105
+ renderMessageInputActions?: (channel: Channel) => React.ReactNode
106
+ onLeaveConversation?: (channel: Channel) => void
107
+ onBlockParticipant?: (participantId?: string) => void
108
+ className?: string
87
109
  }
88
110
 
89
111
  /**
90
112
  * ParticipantPicker component props
91
113
  */
92
114
  export interface ParticipantPickerProps {
93
- participantSource: ParticipantSource;
94
- onSelectParticipant: (participant: Participant) => void;
95
- onClose: () => void;
96
- existingParticipantIds?: Set<string>;
97
- participantLabel?: string;
98
- searchPlaceholder?: string;
99
- className?: string;
115
+ participantSource: ParticipantSource
116
+ onSelectParticipant: (participant: Participant) => void
117
+ onClose: () => void
118
+ existingParticipantIds?: Set<string>
119
+ participantLabel?: string
120
+ searchPlaceholder?: string
121
+ className?: string
100
122
  }
101
123
 
102
124
  /**
103
125
  * MessagingProvider component props
104
126
  */
105
127
  export interface MessagingProviderProps {
106
- children: React.ReactNode;
107
- user: MessagingUser | null;
108
- serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>;
109
- apiKey: string;
110
- capabilities?: MessagingCapabilities;
111
- customization?: MessagingCustomization;
112
- debug?: boolean;
128
+ children: React.ReactNode
129
+ user: MessagingUser | null
130
+ serviceConfig: Omit<StreamChatServiceConfig, 'apiKey'>
131
+ apiKey: string
132
+ capabilities?: MessagingCapabilities
133
+ customization?: MessagingCustomization
134
+ debug?: boolean
113
135
  }