@linktr.ee/messaging-react 1.11.4 → 1.11.5-rc-1765008275

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.4",
3
+ "version": "1.11.5-rc-1765008275",
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'
@@ -43,7 +43,7 @@ type BlockedUser = {
43
43
  const CustomChannelHeader: React.FC<{
44
44
  onBack?: () => void
45
45
  showBackButton: boolean
46
- onShowInfo?: () => void
46
+ onShowInfo: () => void
47
47
  canShowInfo: boolean
48
48
  }> = ({ onBack, showBackButton, onShowInfo, canShowInfo }) => {
49
49
  const { channel } = useChannelStateContext()
@@ -69,6 +69,8 @@ const CustomChannelHeader: React.FC<{
69
69
  !showBackButton && 'invisible'
70
70
  )}
71
71
  onClick={onBack || (() => {})}
72
+ type="button"
73
+ aria-label="Back to conversations"
72
74
  >
73
75
  <ArrowLeftIcon className="size-5 text-black/90" />
74
76
  </button>
@@ -87,7 +89,9 @@ const CustomChannelHeader: React.FC<{
87
89
  className={classNames(
88
90
  'size-10 rounded-full bg-[#F1F0EE] flex items-center justify-center'
89
91
  )}
90
- onClick={onShowInfo || (() => {})}
92
+ onClick={onShowInfo}
93
+ type="button"
94
+ aria-label="Show info"
91
95
  >
92
96
  <DotsThreeIcon className="size-5 text-black/90" />
93
97
  </button>
@@ -136,7 +140,7 @@ const CustomChannelHeader: React.FC<{
136
140
  * Channel info dialog (matching original implementation)
137
141
  */
138
142
  const ChannelInfoDialog: React.FC<{
139
- isOpen: boolean
143
+ dialogRef: React.RefObject<HTMLDialogElement>
140
144
  onClose: () => void
141
145
  participant: ChannelMemberResponse | undefined
142
146
  channel: ChannelType
@@ -144,7 +148,7 @@ const ChannelInfoDialog: React.FC<{
144
148
  onLeaveConversation?: (channel: ChannelType) => void
145
149
  onBlockParticipant?: (participantId?: string) => void
146
150
  }> = ({
147
- isOpen,
151
+ dialogRef,
148
152
  onClose,
149
153
  participant,
150
154
  channel,
@@ -153,24 +157,11 @@ const ChannelInfoDialog: React.FC<{
153
157
  onBlockParticipant,
154
158
  }) => {
155
159
  const { service, debug } = useMessagingContext()
156
- const dialogRef = useRef<HTMLDialogElement>(null)
157
160
  const [isParticipantBlocked, setIsParticipantBlocked] = useState(false)
158
161
  const [isLeaving, setIsLeaving] = useState(false)
159
162
  const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false)
160
163
 
161
- // Sync dialog open state with prop
162
- useEffect(() => {
163
- const dialog = dialogRef.current
164
- if (!dialog) return
165
-
166
- if (isOpen) {
167
- dialog.showModal()
168
- } else {
169
- dialog.close()
170
- }
171
- }, [isOpen])
172
-
173
- // Check if participant is blocked
164
+ // Check if participant is blocked when participant changes
174
165
  const checkIsParticipantBlocked = useCallback(async () => {
175
166
  if (!service || !participant?.user?.id) return
176
167
 
@@ -189,10 +180,8 @@ const ChannelInfoDialog: React.FC<{
189
180
  }, [service, participant?.user?.id])
190
181
 
191
182
  useEffect(() => {
192
- if (isOpen) {
193
- checkIsParticipantBlocked()
194
- }
195
- }, [isOpen, checkIsParticipantBlocked])
183
+ checkIsParticipantBlocked()
184
+ }, [checkIsParticipantBlocked])
196
185
 
197
186
  const handleLeaveConversation = async () => {
198
187
  if (isLeaving) return
@@ -426,7 +415,7 @@ const ChannelViewInner: React.FC<{
426
415
  onBlockParticipant,
427
416
  }) => {
428
417
  const { channel } = useChannelStateContext()
429
- const [showInfo, setShowInfo] = useState(false)
418
+ const infoDialogRef = useRef<HTMLDialogElement>(null)
430
419
 
431
420
  // Get participant info for info dialog
432
421
  const participant = React.useMemo(() => {
@@ -457,6 +446,14 @@ const ChannelViewInner: React.FC<{
457
446
  return undefined
458
447
  }, [channel.data])
459
448
 
449
+ const handleShowInfo = useCallback(() => {
450
+ infoDialogRef.current?.showModal()
451
+ }, [])
452
+
453
+ const handleCloseInfo = useCallback(() => {
454
+ infoDialogRef.current?.close()
455
+ }, [])
456
+
460
457
  return (
461
458
  <>
462
459
  <Window>
@@ -465,7 +462,7 @@ const ChannelViewInner: React.FC<{
465
462
  <CustomChannelHeader
466
463
  onBack={onBack}
467
464
  showBackButton={showBackButton}
468
- onShowInfo={() => setShowInfo(true)}
465
+ onShowInfo={handleShowInfo}
469
466
  canShowInfo={Boolean(participant)}
470
467
  />
471
468
  </div>
@@ -487,8 +484,8 @@ const ChannelViewInner: React.FC<{
487
484
 
488
485
  {/* Channel Info Dialog */}
489
486
  <ChannelInfoDialog
490
- isOpen={showInfo}
491
- onClose={() => setShowInfo(false)}
487
+ dialogRef={infoDialogRef}
488
+ onClose={handleCloseInfo}
492
489
  participant={participant}
493
490
  channel={channel}
494
491
  followerStatusLabel={followerStatusLabel}
@@ -502,37 +499,40 @@ const ChannelViewInner: React.FC<{
502
499
  /**
503
500
  * Channel view component with message list and input
504
501
  */
505
- export const ChannelView: React.FC<ChannelViewProps> = ({
506
- channel,
507
- onBack,
508
- showBackButton = false,
509
- renderMessageInputActions,
510
- onLeaveConversation,
511
- onBlockParticipant,
512
- className,
513
- CustomChannelEmptyState = ChannelEmptyState,
514
- }) => {
515
- return (
516
- <div
517
- className={classNames(
518
- 'messaging-channel-view h-full flex flex-col',
519
- className
520
- )}
521
- >
522
- <Channel
523
- channel={channel}
524
- MessageSystem={CustomSystemMessage}
525
- EmptyStateIndicator={CustomChannelEmptyState}
502
+ export const ChannelView = React.memo<ChannelViewProps>(
503
+ ({
504
+ channel,
505
+ onBack,
506
+ showBackButton = false,
507
+ renderMessageInputActions,
508
+ onLeaveConversation,
509
+ onBlockParticipant,
510
+ className,
511
+ CustomChannelEmptyState = ChannelEmptyState,
512
+ }) => {
513
+ return (
514
+ <div
515
+ className={classNames(
516
+ 'messaging-channel-view h-full flex flex-col',
517
+ className
518
+ )}
526
519
  >
527
- <ChannelViewInner
528
- onBack={onBack}
529
- showBackButton={showBackButton}
530
- renderMessageInputActions={renderMessageInputActions}
531
- onLeaveConversation={onLeaveConversation}
532
- onBlockParticipant={onBlockParticipant}
533
- CustomChannelEmptyState={CustomChannelEmptyState}
534
- />
535
- </Channel>
536
- </div>
537
- )
538
- }
520
+ <Channel
521
+ channel={channel}
522
+ MessageSystem={CustomSystemMessage}
523
+ EmptyStateIndicator={CustomChannelEmptyState}
524
+ >
525
+ <ChannelViewInner
526
+ onBack={onBack}
527
+ showBackButton={showBackButton}
528
+ renderMessageInputActions={renderMessageInputActions}
529
+ onLeaveConversation={onLeaveConversation}
530
+ onBlockParticipant={onBlockParticipant}
531
+ CustomChannelEmptyState={CustomChannelEmptyState}
532
+ />
533
+ </Channel>
534
+ </div>
535
+ )
536
+ }
537
+ )
538
+ ChannelView.displayName = 'ChannelView'
@@ -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'