@linktr.ee/messaging-react 2.6.2-rc-1780478292 → 3.0.0

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/README.md +36 -88
  2. package/dist/{Card-DtoXBrhV.cjs → Card-B7ePjYQ6.cjs} +2 -2
  3. package/dist/{Card-DtoXBrhV.cjs.map → Card-B7ePjYQ6.cjs.map} +1 -1
  4. package/dist/{Card-DmgAq0-y.js → Card-C-ZIQW_q.js} +2 -2
  5. package/dist/{Card-DmgAq0-y.js.map → Card-C-ZIQW_q.js.map} +1 -1
  6. package/dist/{Card-BIb2ouTi.js → Card-C46z9zz4.js} +2 -2
  7. package/dist/{Card-BIb2ouTi.js.map → Card-C46z9zz4.js.map} +1 -1
  8. package/dist/{Card-Dr3LuTAS.cjs → Card-Cq0x0bbb.cjs} +2 -2
  9. package/dist/{Card-Dr3LuTAS.cjs.map → Card-Cq0x0bbb.cjs.map} +1 -1
  10. package/dist/{Card-CafojjYc.js → Card-Cqld0-Ws.js} +3 -3
  11. package/dist/{Card-CafojjYc.js.map → Card-Cqld0-Ws.js.map} +1 -1
  12. package/dist/{Card-DrIyNSwR.cjs → Card-Drz28Q-Y.cjs} +2 -2
  13. package/dist/{Card-DrIyNSwR.cjs.map → Card-Drz28Q-Y.cjs.map} +1 -1
  14. package/dist/{LockedThumbnail-CPAHQ9jA.cjs → LockedThumbnail--h4GTH41.cjs} +2 -2
  15. package/dist/{LockedThumbnail-CPAHQ9jA.cjs.map → LockedThumbnail--h4GTH41.cjs.map} +1 -1
  16. package/dist/{LockedThumbnail-B4LMWHDV.js → LockedThumbnail-D5NHhET2.js} +2 -2
  17. package/dist/{LockedThumbnail-B4LMWHDV.js.map → LockedThumbnail-D5NHhET2.js.map} +1 -1
  18. package/dist/{index-LiNmL1ax.js → index-BUT2yBvJ.js} +1439 -1737
  19. package/dist/index-BUT2yBvJ.js.map +1 -0
  20. package/dist/index-DqNobxVj.cjs +2 -0
  21. package/dist/index-DqNobxVj.cjs.map +1 -0
  22. package/dist/index.cjs +1 -1
  23. package/dist/index.d.ts +29 -63
  24. package/dist/index.js +1 -1
  25. package/package.json +1 -1
  26. package/src/components/ChannelList/index.test.tsx +0 -22
  27. package/src/components/ChannelList/index.tsx +0 -4
  28. package/src/components/ChannelView.test.tsx +0 -11
  29. package/src/components/ChannelView.tsx +3 -21
  30. package/src/components/MessagingShell/MessagingShell.test.tsx +171 -0
  31. package/src/components/MessagingShell/index.tsx +104 -329
  32. package/src/types.ts +23 -81
  33. package/src/utils/getMessageDisplayText.test.ts +0 -32
  34. package/src/utils/getMessageDisplayText.ts +1 -6
  35. package/dist/index-Degc6G3J.cjs +0 -2
  36. package/dist/index-Degc6G3J.cjs.map +0 -1
  37. package/dist/index-LiNmL1ax.js.map +0 -1
  38. package/src/components/CustomMessage/CustomMessage.translation.test.tsx +0 -191
  39. package/src/components/MessagingShell/EmptyState.stories.tsx +0 -35
  40. package/src/components/MessagingShell/EmptyState.tsx +0 -117
@@ -1,149 +1,73 @@
1
- import classNames from 'classnames'
2
- import React, { useState, useCallback, useRef, useEffect } from 'react'
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react'
3
2
  import type { Channel } from 'stream-chat'
4
3
 
5
4
  import { useMessaging } from '../../hooks/useMessaging'
6
5
  import type { MessagingShellProps } from '../../types'
7
- import { ChannelList } from '../ChannelList'
8
6
  import { ChannelView } from '../ChannelView'
9
7
 
10
- import { EmptyState } from './EmptyState'
11
8
  import { ErrorState } from './ErrorState'
12
9
  import { LoadingState } from './LoadingState'
13
10
 
14
11
  /**
15
- * Main messaging interface component that combines channel list and channel view
12
+ * Direct-conversation surface for one specific participant.
13
+ *
14
+ * Renders a single ChannelView for the channel between the connected user and
15
+ * `initialParticipantFilter`. If no channel exists yet and
16
+ * `initialParticipantData` is supplied, the configured StreamChatService
17
+ * channel creator is invoked to create one.
16
18
  */
17
19
  export const MessagingShell: React.FC<MessagingShellProps> = ({
18
20
  capabilities = {},
19
- className,
20
21
  renderMessageInputActions,
21
- renderMessageInputFooter,
22
22
  renderConversationFooter,
23
23
  onChannelSelect,
24
+ onExitConversation,
24
25
  initialParticipantFilter,
25
26
  initialParticipantData,
26
27
  CustomChannelEmptyState,
27
- showChannelList = true,
28
- filters,
29
- channelRenderFilterFn,
30
- channelListCustomEmptyStateIndicator,
31
- onDeleteConversationClick,
32
28
  onBlockParticipantClick,
33
29
  onReportParticipantClick,
34
30
  dmAgentEnabled,
35
- messageMetadata,
36
31
  onMessageSent,
37
- showStarButton = false,
38
32
  chatbotVotingEnabled = false,
39
33
  viewerLanguage,
40
- renderMessagePreview,
41
34
  renderChannelBanner,
42
- customProfileContent,
43
35
  customChannelActions,
44
36
  renderMessage,
45
37
  onMessageLinkClick,
46
- sendButton,
47
- attachmentPreviewList,
48
38
  }) => {
49
39
  const {
50
- service,
51
40
  client,
52
41
  isConnected,
53
42
  isLoading,
54
43
  error,
55
44
  refreshConnection,
45
+ service,
56
46
  debug,
57
47
  } = useMessaging()
58
48
 
59
49
  const [selectedChannel, setSelectedChannel] = useState<Channel | null>(null)
60
- const [hasChannels, setHasChannels] = useState(false)
61
- const [channelsLoaded, setChannelsLoaded] = useState(false)
62
- const [directConversationMode, setDirectConversationMode] = useState(false)
63
50
  const [directConversationError, setDirectConversationError] = useState<
64
51
  string | null
65
52
  >(null)
53
+ const [didExit, setDidExit] = useState(false)
66
54
 
67
55
  const { showDeleteConversation = true } = capabilities
68
56
 
69
- // Create default filters and merge with provided filters
70
- const channelFilters = React.useMemo(() => {
71
- const userId = client?.userID
72
-
73
- // Base filters that should always be present
74
- const baseFilters = {
75
- type: 'messaging',
76
- last_message_at: { $exists: true },
77
- ...(userId && {
78
- members: { $in: [userId] },
79
- hidden: false,
80
- }),
81
- }
82
-
83
- // Merge provided filters with base filters
84
- // Provided filters can override base filters if needed
85
- return {
86
- ...baseFilters,
87
- ...filters,
88
- }
89
- }, [filters, client?.userID])
90
-
91
- // Track if we've already synced channels to prevent repeated API calls
92
- const syncedRef = useRef<string | null>(null)
93
-
94
- // Function to sync channels (extracted for reuse)
95
- const syncChannels = useCallback(async () => {
96
- if (!client || !isConnected) return
97
-
98
- const userId = client.userID
99
- if (!userId) return
100
-
101
- try {
102
- if (debug) {
103
- console.log('[MessagingShell] Syncing channels for user:', userId)
104
- }
105
-
106
- const channels = await client.queryChannels(
107
- {
108
- type: 'messaging',
109
- members: { $in: [userId] },
110
- },
111
- {},
112
- { limit: 100 }
113
- )
114
-
115
- setHasChannels(channels.length > 0)
116
- setChannelsLoaded(true)
117
- syncedRef.current = userId // Mark as synced for this user
57
+ // Stash consumer props that are unstable when passed inline (object
58
+ // literals, arrow-function callbacks) so the load effect's deps stay
59
+ // identity-stable. Without this, the documented usage pattern in the
60
+ // README — passing `initialParticipantData={{ ... }}` and
61
+ // `onChannelSelect={(ch) => ...}` re-fires the effect on every render
62
+ // and triggers a queryChannels call each time.
63
+ const initialParticipantDataRef = useRef(initialParticipantData)
64
+ initialParticipantDataRef.current = initialParticipantData
65
+ const onChannelSelectRef = useRef(onChannelSelect)
66
+ onChannelSelectRef.current = onChannelSelect
118
67
 
119
- if (debug) {
120
- console.log('[MessagingShell] Channels synced successfully:', {
121
- channelCount: channels.length,
122
- })
123
- }
124
- } catch (error) {
125
- console.error('[MessagingShell] Failed to sync channels:', error)
126
- // Don't mark as synced on error, allow retry
127
- }
128
- }, [client, isConnected, debug])
129
-
130
- // Sync existing channels to drive empty-state behavior.
131
68
  useEffect(() => {
132
69
  if (!client || !isConnected) return
133
70
 
134
- const userId = client.userID
135
- if (!userId) return
136
-
137
- // Prevent repeated sync for the same user
138
- if (syncedRef.current === userId) return
139
-
140
- syncChannels()
141
- }, [client, isConnected, syncChannels])
142
-
143
- // Load initial channel for direct conversation mode
144
- useEffect(() => {
145
- if (!initialParticipantFilter || !client || !isConnected) return
146
-
147
71
  const loadInitialChannel = async () => {
148
72
  const userId = client.userID
149
73
  if (!userId) return
@@ -167,13 +91,8 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
167
91
 
168
92
  if (channels.length > 0) {
169
93
  setSelectedChannel(channels[0])
170
- setDirectConversationMode(true)
171
94
  setDirectConversationError(null)
172
-
173
- // Notify parent component of channel selection
174
- if (onChannelSelect) {
175
- onChannelSelect(channels[0])
176
- }
95
+ onChannelSelectRef.current?.(channels[0])
177
96
 
178
97
  if (debug) {
179
98
  console.log(
@@ -181,59 +100,43 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
181
100
  channels[0].id
182
101
  )
183
102
  }
184
- } else {
185
- // No channel found - try to create one if participant data is provided
186
- if (initialParticipantData && service) {
187
- if (debug) {
188
- console.log(
189
- '[MessagingShell] No conversation found, creating one for:',
190
- initialParticipantData
191
- )
192
- }
193
-
194
- try {
195
- // Use the existing service method to create the channel
196
- const channel = await service.startChannelWithParticipant({
197
- id: initialParticipantData.id,
198
- name: initialParticipantData.name,
199
- phone: initialParticipantData.phone,
200
- })
103
+ return
104
+ }
201
105
 
202
- setSelectedChannel(channel)
203
- setDirectConversationMode(true)
204
- setDirectConversationError(null)
106
+ const participantData = initialParticipantDataRef.current
107
+ if (!participantData || !service) {
108
+ setDirectConversationError('No conversation found with this account')
109
+ if (debug) {
110
+ console.log(
111
+ '[MessagingShell] No conversation found for:',
112
+ initialParticipantFilter
113
+ )
114
+ }
115
+ return
116
+ }
205
117
 
206
- // Notify parent component of channel selection
207
- if (onChannelSelect) {
208
- onChannelSelect(channel)
209
- }
118
+ try {
119
+ const channel = await service.startChannelWithParticipant({
120
+ id: participantData.id,
121
+ name: participantData.name,
122
+ phone: participantData.phone,
123
+ })
124
+ setSelectedChannel(channel)
125
+ setDirectConversationError(null)
126
+ onChannelSelectRef.current?.(channel)
210
127
 
211
- if (debug) {
212
- console.log(
213
- '[MessagingShell] Channel created and loaded:',
214
- channel.id
215
- )
216
- }
217
- } catch (createErr) {
218
- console.error(
219
- '[MessagingShell] Failed to create conversation:',
220
- createErr
221
- )
222
- setDirectConversationError('Failed to create conversation')
223
- }
224
- } else {
225
- // No participant data provided, show error
226
- setDirectConversationError(
227
- 'No conversation found with this account'
128
+ if (debug) {
129
+ console.log(
130
+ '[MessagingShell] Channel created and loaded:',
131
+ channel.id
228
132
  )
229
-
230
- if (debug) {
231
- console.log(
232
- '[MessagingShell] No conversation found for:',
233
- initialParticipantFilter
234
- )
235
- }
236
133
  }
134
+ } catch (createErr) {
135
+ console.error(
136
+ '[MessagingShell] Failed to create conversation:',
137
+ createErr
138
+ )
139
+ setDirectConversationError('Failed to create conversation')
237
140
  }
238
141
  } catch (err) {
239
142
  console.error(
@@ -244,202 +147,74 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
244
147
  }
245
148
  }
246
149
 
247
- loadInitialChannel()
248
- }, [
249
- initialParticipantFilter,
250
- initialParticipantData,
251
- client,
252
- isConnected,
253
- service,
254
- debug,
255
- onChannelSelect,
256
- ])
257
-
258
- const handleChannelSelect = useCallback(
259
- (channel: Channel) => {
260
- setSelectedChannel(channel)
261
- onChannelSelect?.(channel)
262
- },
263
- [onChannelSelect]
264
- )
265
-
266
- const handleBackToChannelList = useCallback(() => {
267
- // In direct conversation mode, don't allow going back to channel list
268
- // The parent component should handle navigation
269
- if (directConversationMode) return
270
-
150
+ void loadInitialChannel()
151
+ }, [initialParticipantFilter, client, isConnected, service, debug])
152
+
153
+ // Leave / block clears the selected channel and notifies the consumer.
154
+ // The consumer owns what happens next — typically unmounting MessagingShell
155
+ // or re-rendering with new participant data. When no callback is provided,
156
+ // the shell renders an "ended" state rather than an indefinite spinner so
157
+ // the surface remains honest about why no conversation is shown.
158
+ const onExitConversationRef = useRef(onExitConversation)
159
+ onExitConversationRef.current = onExitConversation
160
+ const handleExitConversation = useCallback(() => {
271
161
  setSelectedChannel(null)
272
- }, [directConversationMode])
273
-
274
- const handleLeaveConversation = useCallback(
275
- async (channel: Channel) => {
276
- if (debug) {
277
- console.log('[MessagingShell] Leaving conversation:', channel.id)
278
- }
279
- setSelectedChannel(null)
280
- setDirectConversationMode(false) // Exit direct conversation mode
281
-
282
- // Force re-sync to update the existing participants list
283
- syncedRef.current = null
284
- await syncChannels()
285
- },
286
- [syncChannels, debug]
287
- )
288
-
289
- const handleBlockParticipant = useCallback(
290
- async (participantId?: string) => {
291
- if (debug) {
292
- console.log('[MessagingShell] Blocking participant:', participantId)
293
- }
294
- setSelectedChannel(null)
295
- setDirectConversationMode(false) // Exit direct conversation mode
296
-
297
- // Force re-sync to update the existing participants list
298
- syncedRef.current = null
299
- await syncChannels()
300
- },
301
- [syncChannels, debug]
302
- )
303
-
304
- const isChannelSelected = Boolean(selectedChannel)
162
+ setDidExit(true)
163
+ onExitConversationRef.current?.()
164
+ }, [])
305
165
 
306
- // Show loading state
307
166
  if (isLoading) {
308
- return (
309
- <div className={classNames('h-full', className)}>
310
- <LoadingState />
311
- </div>
312
- )
167
+ return <LoadingState />
313
168
  }
314
169
 
315
- // Show error state
316
170
  if (error) {
317
- return (
318
- <div className={classNames('h-full', className)}>
319
- <ErrorState message={error} onBack={refreshConnection} />
320
- </div>
321
- )
171
+ return <ErrorState message={error} onBack={refreshConnection} />
322
172
  }
323
173
 
324
- // Show not connected state
325
174
  if (!isConnected || !client) {
326
175
  return (
327
- <div className={classNames('h-full', className)}>
328
- <ErrorState
329
- message="Not connected to messaging service"
330
- onBack={refreshConnection}
331
- />
332
- </div>
176
+ <ErrorState
177
+ message="Not connected to messaging service"
178
+ onBack={refreshConnection}
179
+ />
333
180
  )
334
181
  }
335
182
 
336
- // Show direct conversation error state
337
183
  if (directConversationError) {
338
- return (
339
- <div className={classNames('h-full', className)}>
340
- <ErrorState message={directConversationError} />
341
- </div>
342
- )
184
+ return <ErrorState message={directConversationError} />
343
185
  }
344
186
 
345
- return (
346
- <div
347
- className={classNames(
348
- 'messaging-shell h-full bg-background-primary overflow-hidden',
349
- className
350
- )}
351
- >
352
- <div className="flex h-full min-h-0">
353
- {/* Channel List Sidebar */}
354
- <div
355
- className={classNames(
356
- 'messaging-channel-list-sidebar min-h-0 min-w-0 lg:flex lg:flex-col',
357
- {
358
- '!hidden': showChannelList === false || directConversationMode,
359
- // Hide on mobile when channel selected, show on desktop with consistent wide width
360
- 'hidden lg:flex lg:flex-1 lg:max-w-2xl':
361
- showChannelList !== false &&
362
- !directConversationMode &&
363
- isChannelSelected,
364
- // Show on mobile when no channel selected, use same wide width on desktop
365
- 'flex flex-col w-full lg:flex-1 lg:max-w-2xl':
366
- showChannelList !== false &&
367
- !directConversationMode &&
368
- !isChannelSelected,
369
- }
370
- )}
371
- >
372
- <ChannelList
373
- onChannelSelect={handleChannelSelect}
374
- selectedChannel={selectedChannel || undefined}
375
- filters={channelFilters}
376
- channelRenderFilterFn={channelRenderFilterFn}
377
- customEmptyStateIndicator={channelListCustomEmptyStateIndicator}
378
- renderMessagePreview={renderMessagePreview}
379
- viewerLanguage={viewerLanguage}
380
- />
381
- </div>
187
+ if (didExit && !selectedChannel) {
188
+ return <ErrorState message="Conversation ended" />
189
+ }
382
190
 
383
- {/* Channel View */}
384
- <div
385
- className={classNames(
386
- 'messaging-conversation-view flex-1 flex-col min-w-0 min-h-0',
387
- {
388
- // In direct conversation mode (or waiting for it), always show (full width)
389
- flex:
390
- directConversationMode ||
391
- isChannelSelected ||
392
- initialParticipantFilter,
393
- // Normal mode: hide on mobile when no channel selected
394
- 'hidden lg:flex':
395
- !directConversationMode &&
396
- !isChannelSelected &&
397
- !initialParticipantFilter,
398
- }
399
- )}
400
- >
401
- {selectedChannel ? (
402
- <div className="flex-1 min-h-0 flex flex-col">
403
- <ChannelView
404
- channel={selectedChannel}
405
- key={selectedChannel.id}
406
- onBack={handleBackToChannelList}
407
- showBackButton={!directConversationMode}
408
- renderMessageInputActions={renderMessageInputActions}
409
- renderMessageInputFooter={renderMessageInputFooter}
410
- renderConversationFooter={renderConversationFooter}
411
- renderChannelBanner={renderChannelBanner}
412
- onLeaveConversation={handleLeaveConversation}
413
- onBlockParticipant={handleBlockParticipant}
414
- CustomChannelEmptyState={CustomChannelEmptyState}
415
- showDeleteConversation={showDeleteConversation}
416
- onDeleteConversationClick={onDeleteConversationClick}
417
- onBlockParticipantClick={onBlockParticipantClick}
418
- onReportParticipantClick={onReportParticipantClick}
419
- dmAgentEnabled={dmAgentEnabled}
420
- messageMetadata={messageMetadata}
421
- onMessageSent={onMessageSent}
422
- showStarButton={showStarButton}
423
- chatbotVotingEnabled={chatbotVotingEnabled}
424
- viewerLanguage={viewerLanguage}
425
- customProfileContent={customProfileContent}
426
- customChannelActions={customChannelActions}
427
- renderMessage={renderMessage}
428
- onMessageLinkClick={onMessageLinkClick}
429
- sendButton={sendButton}
430
- attachmentPreviewList={attachmentPreviewList}
431
- />
432
- </div>
433
- ) : initialParticipantFilter ? (
434
- // Show loading while creating/loading direct conversation channel
435
- <LoadingState />
436
- ) : (
437
- <EmptyState
438
- hasChannels={hasChannels}
439
- channelsLoaded={channelsLoaded}
440
- />
441
- )}
442
- </div>
191
+ if (!selectedChannel) {
192
+ return <LoadingState />
193
+ }
194
+
195
+ return (
196
+ <div className="messaging-shell h-full bg-background-primary overflow-hidden">
197
+ <div className="flex h-full min-h-0 flex-col">
198
+ <ChannelView
199
+ channel={selectedChannel}
200
+ key={selectedChannel.id}
201
+ renderMessageInputActions={renderMessageInputActions}
202
+ renderConversationFooter={renderConversationFooter}
203
+ renderChannelBanner={renderChannelBanner}
204
+ onLeaveConversation={handleExitConversation}
205
+ onBlockParticipant={handleExitConversation}
206
+ CustomChannelEmptyState={CustomChannelEmptyState}
207
+ showDeleteConversation={showDeleteConversation}
208
+ onBlockParticipantClick={onBlockParticipantClick}
209
+ onReportParticipantClick={onReportParticipantClick}
210
+ dmAgentEnabled={dmAgentEnabled}
211
+ onMessageSent={onMessageSent}
212
+ chatbotVotingEnabled={chatbotVotingEnabled}
213
+ viewerLanguage={viewerLanguage}
214
+ customChannelActions={customChannelActions}
215
+ renderMessage={renderMessage}
216
+ onMessageLinkClick={onMessageLinkClick}
217
+ />
443
218
  </div>
444
219
  </div>
445
220
  )