@linktr.ee/messaging-react 1.11.3 → 1.11.5-rc-1765005296

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.11.3",
3
+ "version": "1.11.5-rc-1765005296",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -6,123 +6,126 @@ import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
6
6
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
7
7
  import { Avatar } from '../Avatar'
8
8
 
9
+ type CustomChannelPreviewProps = ChannelPreviewUIComponentProps & {
10
+ selectedChannel?: Channel | null
11
+ onChannelSelect: (channel: Channel) => void
12
+ debug?: boolean
13
+ }
14
+
9
15
  /**
10
16
  * Custom channel preview that handles selection
11
17
  */
12
- const CustomChannelPreview: React.FC<
13
- ChannelPreviewUIComponentProps & {
14
- selectedChannel?: Channel | null
15
- onChannelSelect: (channel: Channel) => void
16
- debug?: boolean
17
- }
18
- > = ({ channel, selectedChannel, onChannelSelect, debug = false, unread }) => {
19
- const isSelected = selectedChannel?.id === channel?.id
18
+ const CustomChannelPreview = React.memo<CustomChannelPreviewProps>(
19
+ ({ channel, selectedChannel, onChannelSelect, debug = false, unread }) => {
20
+ const isSelected = selectedChannel?.id === channel?.id
20
21
 
21
- const handleClick = () => {
22
- if (channel) {
23
- onChannelSelect(channel)
22
+ const handleClick = () => {
23
+ if (channel) {
24
+ onChannelSelect(channel)
25
+ }
24
26
  }
25
- }
26
27
 
27
- // Get participant info
28
- const members = Object.values(channel?.state?.members || {})
29
- const participant = members.find(
30
- (member) => member.user?.id && member.user.id !== channel?._client?.userID
31
- )
32
- const participantName = participant?.user?.name || 'Conversation'
33
- const participantImage = participant?.user?.image
34
-
35
- // Get last message and format timestamp
36
- const lastMessage =
37
- channel?.state?.messages?.[channel.state.messages.length - 1]
38
-
39
- // Fallback order: text -> attachment URL -> "No messages yet"
40
- const getLastMessageText = () => {
41
- if (lastMessage?.text) return lastMessage.text
42
-
43
- const attachment = lastMessage?.attachments?.[0]
44
- if (attachment?.asset_url) return attachment.asset_url
45
- if (attachment?.image_url) return attachment.image_url
46
- if (attachment?.og_scrape_url) return attachment.og_scrape_url
47
- if (attachment?.thumb_url) return attachment.thumb_url
48
-
49
- return 'No messages yet'
50
- }
28
+ // Get participant info
29
+ const members = Object.values(channel?.state?.members || {})
30
+ const participant = members.find(
31
+ (member) => member.user?.id && member.user.id !== channel?._client?.userID
32
+ )
33
+ const participantName = participant?.user?.name || 'Conversation'
34
+ const participantImage = participant?.user?.image
51
35
 
52
- const lastMessageText = getLastMessageText()
53
- const lastMessageTime = lastMessage?.created_at
54
- ? formatRelativeTime(new Date(lastMessage.created_at))
55
- : ''
56
-
57
- // Use the unread prop passed by Stream Chat (reactive and updates automatically)
58
- const unreadCount = unread ?? 0
59
-
60
- if (debug) {
61
- console.log('📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER', {
62
- channelId: channel?.id,
63
- isSelected,
64
- participantName,
65
- unreadCount,
66
- hasTimestamp: !!lastMessageTime,
67
- })
68
- }
36
+ // Get last message and format timestamp
37
+ const lastMessage =
38
+ channel?.state?.messages?.[channel.state.messages.length - 1]
39
+
40
+ // Fallback order: text -> attachment URL -> "No messages yet"
41
+ const getLastMessageText = () => {
42
+ if (lastMessage?.text) return lastMessage.text
43
+
44
+ const attachment = lastMessage?.attachments?.[0]
45
+ if (attachment?.asset_url) return attachment.asset_url
46
+ if (attachment?.image_url) return attachment.image_url
47
+ if (attachment?.og_scrape_url) return attachment.og_scrape_url
48
+ if (attachment?.thumb_url) return attachment.thumb_url
49
+
50
+ return 'No messages yet'
51
+ }
69
52
 
70
- return (
71
- <button
72
- type="button"
73
- onClick={handleClick}
74
- className={classNames(
75
- 'group w-full px-4 py-3 transition-colors text-left max-w-full overflow-hidden focus-ring',
76
- {
77
- 'bg-primary-alt/10 border-l-4 border-l-primary': isSelected,
78
- 'hover:bg-sand': !isSelected,
79
- }
80
- )}
81
- >
82
- <div className="flex items-start gap-3">
83
- {/* Avatar */}
84
- <Avatar
85
- id={participant?.user?.id || channel.id || 'unknown'}
86
- name={participantName}
87
- image={participantImage}
88
- size={44}
89
- className="[&_.avatar-fallback]:group-hover:bg-[#eeeeee]"
90
- />
91
-
92
- {/* Content column */}
93
- <div className="flex-1 min-w-0 flex flex-col gap-1">
94
- {/* Name and timestamp row */}
95
- <div className="flex items-center justify-between gap-2">
96
- <h3
97
- className={classNames(
98
- 'text-sm font-medium truncate',
99
- isSelected ? 'text-primary' : 'text-charcoal'
53
+ const lastMessageText = getLastMessageText()
54
+ const lastMessageTime = lastMessage?.created_at
55
+ ? formatRelativeTime(new Date(lastMessage.created_at))
56
+ : ''
57
+
58
+ // Use the unread prop passed by Stream Chat (reactive and updates automatically)
59
+ const unreadCount = unread ?? 0
60
+
61
+ if (debug) {
62
+ console.log('📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER', {
63
+ channelId: channel?.id,
64
+ isSelected,
65
+ participantName,
66
+ unreadCount,
67
+ hasTimestamp: !!lastMessageTime,
68
+ })
69
+ }
70
+
71
+ return (
72
+ <button
73
+ type="button"
74
+ onClick={handleClick}
75
+ className={classNames(
76
+ 'group w-full px-4 py-3 transition-colors text-left max-w-full overflow-hidden focus-ring',
77
+ {
78
+ 'bg-primary-alt/10 border-l-4 border-l-primary': isSelected,
79
+ 'hover:bg-sand': !isSelected,
80
+ }
81
+ )}
82
+ >
83
+ <div className="flex items-start gap-3">
84
+ {/* Avatar */}
85
+ <Avatar
86
+ id={participant?.user?.id || channel.id || 'unknown'}
87
+ name={participantName}
88
+ image={participantImage}
89
+ size={44}
90
+ className="[&_.avatar-fallback]:group-hover:bg-[#eeeeee]"
91
+ />
92
+
93
+ {/* Content column */}
94
+ <div className="flex-1 min-w-0 flex flex-col gap-1">
95
+ {/* Name and timestamp row */}
96
+ <div className="flex items-center justify-between gap-2">
97
+ <h3
98
+ className={classNames(
99
+ 'text-sm font-medium truncate',
100
+ isSelected ? 'text-primary' : 'text-charcoal'
101
+ )}
102
+ >
103
+ {participantName}
104
+ </h3>
105
+ {lastMessageTime && (
106
+ <span className="text-xs text-stone flex-shrink-0">
107
+ {lastMessageTime}
108
+ </span>
100
109
  )}
101
- >
102
- {participantName}
103
- </h3>
104
- {lastMessageTime && (
105
- <span className="text-xs text-stone flex-shrink-0">
106
- {lastMessageTime}
107
- </span>
108
- )}
109
- </div>
110
+ </div>
110
111
 
111
- {/* Message and unread badge row */}
112
- <div className="flex items-center justify-between gap-2 min-w-0">
113
- <p className="text-xs text-stone mr-2 flex-1 line-clamp-2">
114
- {lastMessageText}
115
- </p>
116
- {unreadCount > 0 && (
117
- <span className="bg-[#7f22fe] text-white text-xs px-2 py-0.5 rounded-full min-w-[20px] text-center flex-shrink-0">
118
- {unreadCount > 99 ? '99+' : unreadCount}
119
- </span>
120
- )}
112
+ {/* Message and unread badge row */}
113
+ <div className="flex items-center justify-between gap-2 min-w-0">
114
+ <p className="text-xs text-stone mr-2 flex-1 line-clamp-2">
115
+ {lastMessageText}
116
+ </p>
117
+ {unreadCount > 0 && (
118
+ <span className="bg-[#7f22fe] text-white text-xs px-2 py-0.5 rounded-full min-w-[20px] text-center flex-shrink-0">
119
+ {unreadCount > 99 ? '99+' : unreadCount}
120
+ </span>
121
+ )}
122
+ </div>
121
123
  </div>
122
124
  </div>
123
- </div>
124
- </button>
125
- )
126
- }
125
+ </button>
126
+ )
127
+ }
128
+ )
127
129
 
128
130
  export default CustomChannelPreview
131
+ CustomChannelPreview.displayName = 'CustomChannelPreview'
@@ -1,5 +1,6 @@
1
1
  import classNames from 'classnames'
2
2
  import React from 'react'
3
+ import type { ChannelPreviewUIComponentProps } from 'stream-chat-react'
3
4
  import { ChannelList as StreamChannelList } from 'stream-chat-react'
4
5
 
5
6
  import { useMessagingContext } from '../../providers/MessagingProvider'
@@ -10,74 +11,62 @@ import CustomChannelPreview from './CustomChannelPreview'
10
11
  /**
11
12
  * Channel list component with customizable header and actions
12
13
  */
13
- export const ChannelList: React.FC<ChannelListProps> = ({
14
- onChannelSelect,
15
- selectedChannel,
16
- filters,
17
- className,
18
- customEmptyStateIndicator,
19
- }) => {
20
- // Track renders
21
- const renderCountRef = React.useRef(0)
22
- renderCountRef.current++
14
+ export const ChannelList = React.memo<ChannelListProps>(
15
+ ({
16
+ onChannelSelect,
17
+ selectedChannel,
18
+ filters,
19
+ className,
20
+ customEmptyStateIndicator,
21
+ }) => {
22
+ // Track renders
23
+ const renderCountRef = React.useRef(0)
24
+ renderCountRef.current++
23
25
 
24
- // Get debug flag from context
25
- const { debug = false } = useMessagingContext()
26
+ // Get debug flag from context
27
+ const { debug = false } = useMessagingContext()
26
28
 
27
- if (debug) {
28
- console.log('📺 [ChannelList] 🔄 RENDER START', {
29
- renderCount: renderCountRef.current,
30
- selectedChannelId: selectedChannel?.id,
31
- filters,
32
- })
33
- }
34
-
35
- return (
36
- <div
37
- className={classNames(
38
- 'messaging-channel-list h-full flex flex-col min-w-0 overflow-hidden',
39
- className
40
- )}
41
- >
42
- {/* Channel List */}
43
- <div className="flex-1 overflow-hidden min-w-0">
44
- {(() => {
45
- if (debug) {
46
- console.log('📺 [ChannelList] 🎬 RENDERING STREAM CHANNEL LIST', {
47
- renderCount: renderCountRef.current,
48
- filters,
49
- })
50
- }
29
+ if (debug) {
30
+ console.log('📺 [ChannelList] 🔄 RENDER START', {
31
+ renderCount: renderCountRef.current,
32
+ selectedChannelId: selectedChannel?.id,
33
+ filters,
34
+ })
35
+ }
51
36
 
52
- return (
53
- <StreamChannelList
54
- key={JSON.stringify(filters)}
55
- filters={filters}
56
- sort={{ last_message_at: -1 }}
57
- options={{ limit: 30 }}
58
- Preview={(props) => {
59
- if (debug) {
60
- console.log('📺 [ChannelList] 📋 CHANNEL PREVIEW RENDER', {
61
- channelId: props.channel?.id,
62
- selectedChannelId: selectedChannel?.id,
63
- isSelected: selectedChannel?.id === props.channel?.id,
64
- })
65
- }
37
+ // Memoize Preview component to prevent re-renders
38
+ const PreviewComponent = React.useMemo(() => {
39
+ const Preview = (props: ChannelPreviewUIComponentProps) => (
40
+ <CustomChannelPreview
41
+ {...props}
42
+ selectedChannel={selectedChannel}
43
+ onChannelSelect={onChannelSelect}
44
+ debug={debug}
45
+ />
46
+ )
47
+ return Preview
48
+ }, [selectedChannel, onChannelSelect, debug])
66
49
 
67
- return (
68
- <CustomChannelPreview
69
- {...props}
70
- selectedChannel={selectedChannel}
71
- onChannelSelect={onChannelSelect}
72
- debug={debug}
73
- />
74
- )
75
- }}
76
- EmptyStateIndicator={customEmptyStateIndicator}
77
- />
78
- )
79
- })()}
50
+ return (
51
+ <div
52
+ className={classNames(
53
+ 'messaging-channel-list h-full flex flex-col min-w-0 overflow-hidden',
54
+ className
55
+ )}
56
+ >
57
+ {/* Channel List */}
58
+ <div className="flex-1 overflow-hidden min-w-0">
59
+ <StreamChannelList
60
+ key={JSON.stringify(filters)}
61
+ filters={filters}
62
+ sort={{ last_message_at: -1 }}
63
+ options={{ limit: 30 }}
64
+ Preview={PreviewComponent}
65
+ EmptyStateIndicator={customEmptyStateIndicator}
66
+ />
67
+ </div>
80
68
  </div>
81
- </div>
82
- )
83
- }
69
+ )
70
+ }
71
+ )
72
+ ChannelList.displayName = 'ChannelList'
@@ -424,14 +424,10 @@ const ChannelViewInner: React.FC<{
424
424
  renderMessageInputActions,
425
425
  onLeaveConversation,
426
426
  onBlockParticipant,
427
- CustomChannelEmptyState = ChannelEmptyState,
428
427
  }) => {
429
428
  const { channel } = useChannelStateContext()
430
429
  const [showInfo, setShowInfo] = useState(false)
431
430
 
432
- // Check if channel has messages - using context to reactively subscribe to message updates
433
- const hasMessages = (channel?.state?.messages?.length ?? 0) > 0
434
-
435
431
  // Get participant info for info dialog
436
432
  const participant = React.useMemo(() => {
437
433
  const members = Object.values(channel.state.members || {})
@@ -481,13 +477,6 @@ const ChannelViewInner: React.FC<{
481
477
  hideNewMessageSeparator={false}
482
478
  messageActions={[]}
483
479
  />
484
-
485
- {/* Show custom empty state when no messages */}
486
- {!hasMessages && CustomChannelEmptyState && (
487
- <div className="absolute inset-0 w-full h-full">
488
- <CustomChannelEmptyState />
489
- </div>
490
- )}
491
480
  </div>
492
481
 
493
482
  {/* Message Input */}
@@ -513,33 +502,40 @@ const ChannelViewInner: React.FC<{
513
502
  /**
514
503
  * Channel view component with message list and input
515
504
  */
516
- export const ChannelView: React.FC<ChannelViewProps> = ({
517
- channel,
518
- onBack,
519
- showBackButton = false,
520
- renderMessageInputActions,
521
- onLeaveConversation,
522
- onBlockParticipant,
523
- className,
524
- CustomChannelEmptyState = ChannelEmptyState,
525
- }) => {
526
- return (
527
- <div
528
- className={classNames(
529
- 'messaging-channel-view h-full flex flex-col',
530
- className
531
- )}
532
- >
533
- <Channel channel={channel} MessageSystem={CustomSystemMessage}>
534
- <ChannelViewInner
535
- onBack={onBack}
536
- showBackButton={showBackButton}
537
- renderMessageInputActions={renderMessageInputActions}
538
- onLeaveConversation={onLeaveConversation}
539
- onBlockParticipant={onBlockParticipant}
540
- CustomChannelEmptyState={CustomChannelEmptyState}
541
- />
542
- </Channel>
543
- </div>
544
- )
545
- }
505
+ export const ChannelView = React.memo<ChannelViewProps>(
506
+ ({
507
+ channel,
508
+ onBack,
509
+ showBackButton = false,
510
+ renderMessageInputActions,
511
+ onLeaveConversation,
512
+ onBlockParticipant,
513
+ className,
514
+ CustomChannelEmptyState = ChannelEmptyState,
515
+ }) => {
516
+ return (
517
+ <div
518
+ className={classNames(
519
+ 'messaging-channel-view h-full flex flex-col',
520
+ className
521
+ )}
522
+ >
523
+ <Channel
524
+ channel={channel}
525
+ MessageSystem={CustomSystemMessage}
526
+ EmptyStateIndicator={CustomChannelEmptyState}
527
+ >
528
+ <ChannelViewInner
529
+ onBack={onBack}
530
+ showBackButton={showBackButton}
531
+ renderMessageInputActions={renderMessageInputActions}
532
+ onLeaveConversation={onLeaveConversation}
533
+ onBlockParticipant={onBlockParticipant}
534
+ CustomChannelEmptyState={CustomChannelEmptyState}
535
+ />
536
+ </Channel>
537
+ </div>
538
+ )
539
+ }
540
+ )
541
+ ChannelView.displayName = 'ChannelView'
@@ -3,15 +3,4 @@ import React from 'react'
3
3
  /**
4
4
  * Empty state component shown when a channel has no messages
5
5
  */
6
- export const ChannelEmptyState: React.FC = () => (
7
- <div className="messaging-channel-empty-state flex items-center justify-center h-full p-8 text-balance">
8
- <div className="text-center max-w-sm">
9
- <h2 className="font-semibold text-charcoal mb-2">No messages yet 👀</h2>
10
-
11
- <p className="text-stone text-xs">
12
- Share to social media to generate more conversations
13
- </p>
14
- </div>
15
- </div>
16
- )
17
-
6
+ export const ChannelEmptyState: React.FC = () => null
@@ -96,9 +96,8 @@ const ChatBubblesIllustration = ({ className }: { className?: string }) => (
96
96
  /**
97
97
  * Empty state component shown when no channel is selected
98
98
  */
99
- export const EmptyState: React.FC<{
100
- hasChannels: boolean
101
- }> = ({ hasChannels }) => (
99
+ export const EmptyState = React.memo<{ hasChannels: boolean }>(
100
+ ({ hasChannels }) => (
102
101
  <div className="messaging-empty-state flex items-center justify-center h-full p-8 text-balance">
103
102
  <div className="flex flex-col items-center max-w-sm text-center">
104
103
  <ChatBubblesIllustration />
@@ -114,4 +113,5 @@ export const EmptyState: React.FC<{
114
113
  )}
115
114
  </div>
116
115
  </div>
117
- )
116
+ ))
117
+ EmptyState.displayName = 'EmptyState'
@@ -1,12 +1,14 @@
1
1
  import React from 'react'
2
2
 
3
+ type ErrorStateProps = {
4
+ message: string
5
+ onBack?: () => void
6
+ }
7
+
3
8
  /**
4
9
  * Error state component shown when something goes wrong
5
10
  */
6
- export const ErrorState: React.FC<{
7
- message: string
8
- onBack?: () => void
9
- }> = ({ message, onBack }) => (
11
+ export const ErrorState = React.memo<ErrorStateProps>(({ message, onBack }) => (
10
12
  <div className="messaging-error-state flex items-center justify-center h-full p-8">
11
13
  <div className="text-center max-w-sm">
12
14
  <div className="w-24 h-24 bg-danger-alt/20 rounded-full flex items-center justify-center mx-auto mb-6">
@@ -28,4 +30,5 @@ export const ErrorState: React.FC<{
28
30
  )}
29
31
  </div>
30
32
  </div>
31
- )
33
+ ))
34
+ ErrorState.displayName = 'ErrorState'
@@ -1,13 +1,16 @@
1
+ import React from 'react'
2
+
1
3
  import Loading from '../Loading'
2
4
 
3
5
  /**
4
6
  * Loading state component
5
7
  */
6
- export const LoadingState = () => (
8
+ export const LoadingState = React.memo(() => (
7
9
  <div className="messaging-loading-state flex items-center justify-center h-full">
8
10
  <div className="flex items-center">
9
11
  <Loading className="w-6 h-6" />
10
12
  <span className="text-sm text-stone">Loading messages</span>
11
13
  </div>
12
14
  </div>
13
- )
15
+ ))
16
+ LoadingState.displayName = 'LoadingState'
@@ -111,7 +111,16 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
111
111
  })
112
112
  })
113
113
 
114
- setExistingParticipantIds(memberIds)
114
+ // Only update if the set contents have changed to prevent re-renders
115
+ setExistingParticipantIds((prev) => {
116
+ if (
117
+ prev.size === memberIds.size &&
118
+ [...prev].every((id) => memberIds.has(id))
119
+ ) {
120
+ return prev
121
+ }
122
+ return memberIds
123
+ })
115
124
  setHasChannels(channels.length > 0)
116
125
  syncedRef.current = userId // Mark as synced for this user
117
126
 
@@ -315,6 +324,15 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
315
324
  participantPickerRef.current?.close()
316
325
  }, [])
317
326
 
327
+ const handleDialogBackdropClick = useCallback(
328
+ (e: React.MouseEvent<HTMLDialogElement>) => {
329
+ if (e.target === participantPickerRef.current) {
330
+ handleCloseParticipantPicker()
331
+ }
332
+ },
333
+ [handleCloseParticipantPicker]
334
+ )
335
+
318
336
  const handleLeaveConversation = useCallback(
319
337
  async (channel: Channel) => {
320
338
  if (debug) {
@@ -459,11 +477,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
459
477
  <dialog
460
478
  ref={participantPickerRef}
461
479
  className="mes-dialog"
462
- onClick={(e) => {
463
- if (e.target === participantPickerRef.current) {
464
- handleCloseParticipantPicker()
465
- }
466
- }}
480
+ onClick={handleDialogBackdropClick}
467
481
  onClose={handleCloseParticipantPicker}
468
482
  >
469
483
  <div className="h-full w-full bg-white shadow-max-elevation-light">