@linktr.ee/messaging-react 1.24.4 → 1.25.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.
@@ -1,3 +1,4 @@
1
+ import { act } from '@testing-library/react'
1
2
  import { Channel, LocalMessage, StreamChat } from 'stream-chat'
2
3
  import { describe, expect, it, vi } from 'vitest'
3
4
 
@@ -10,10 +11,16 @@ const mockUser = {
10
11
  name: 'Current User',
11
12
  }
12
13
 
13
- const createMockChannel = (
14
- messages: Partial<LocalMessage>[]
15
- ): Channel =>
16
- ({
14
+ type MockChannel = Channel & {
15
+ emitMemberUpdated: (member?: { pinned_at?: string | null }) => void
16
+ }
17
+
18
+ const createMockChannel = (messages: Partial<LocalMessage>[]): MockChannel => {
19
+ const memberUpdatedListeners = new Set<
20
+ (event: { member?: { pinned_at?: string | null } }) => void
21
+ >()
22
+
23
+ return {
17
24
  id: 'channel-1',
18
25
  cid: 'messaging:channel-1',
19
26
  _client: { userID: mockUser.id } as unknown as StreamChat,
@@ -26,8 +33,33 @@ const createMockChannel = (
26
33
  },
27
34
  },
28
35
  messages: messages as LocalMessage[],
36
+ membership: {},
29
37
  },
30
- }) as unknown as Channel
38
+ on: vi.fn(
39
+ (
40
+ eventName: string,
41
+ listener: (event: { member?: { pinned_at?: string | null } }) => void
42
+ ) => {
43
+ if (eventName === 'member.updated') {
44
+ memberUpdatedListeners.add(listener)
45
+ }
46
+ }
47
+ ),
48
+ off: vi.fn(
49
+ (
50
+ eventName: string,
51
+ listener: (event: { member?: { pinned_at?: string | null } }) => void
52
+ ) => {
53
+ if (eventName === 'member.updated') {
54
+ memberUpdatedListeners.delete(listener)
55
+ }
56
+ }
57
+ ),
58
+ emitMemberUpdated: (member?: { pinned_at?: string | null }) => {
59
+ memberUpdatedListeners.forEach((listener) => listener({ member }))
60
+ },
61
+ } as unknown as MockChannel
62
+ }
31
63
 
32
64
  describe('CustomChannelPreview', () => {
33
65
  const defaultProps = {
@@ -154,4 +186,24 @@ describe('CustomChannelPreview', () => {
154
186
 
155
187
  expect(screen.getByText('No messages yet')).toBeInTheDocument()
156
188
  })
189
+
190
+ it('updates starred state when the channel membership changes', () => {
191
+ const channel = createMockChannel([])
192
+
193
+ renderWithProviders(
194
+ <CustomChannelPreview {...defaultProps} channel={channel} />
195
+ )
196
+
197
+ expect(
198
+ screen.queryByRole('heading', { name: /starred conversation/i })
199
+ ).not.toBeInTheDocument()
200
+
201
+ act(() => {
202
+ channel.emitMemberUpdated({ pinned_at: new Date().toISOString() })
203
+ })
204
+
205
+ expect(
206
+ screen.getByRole('heading', { name: /starred conversation/i })
207
+ ).toBeInTheDocument()
208
+ })
157
209
  })
@@ -2,6 +2,7 @@ import classNames from 'classnames'
2
2
  import React from 'react'
3
3
  import { ChannelPreviewUIComponentProps } from 'stream-chat-react'
4
4
 
5
+ import { useChannelStar } from '../../hooks/useChannelStar'
5
6
  import { formatRelativeTime } from '../../utils/formatRelativeTime'
6
7
  import { Avatar } from '../Avatar'
7
8
  import { isChatbotMessage } from '../CustomMessage/MessageTag'
@@ -53,6 +54,8 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
53
54
 
54
55
  const getLastMessageText = () => {
55
56
  if (lastMessage?.text) return lastMessage.text
57
+ const isTip = lastMessage?.metadata?.custom_type === 'MESSAGE_TIP'
58
+ if (isTip) return '💵 Sent a tip'
56
59
 
57
60
  const attachment = lastMessage?.attachments?.[0]
58
61
  if (attachment) {
@@ -83,6 +86,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
83
86
  const messagePreview = renderMessagePreview
84
87
  ? renderMessagePreview(lastMessage, lastMessageText)
85
88
  : `${isLastMessageFromChatbot ? '✨ ' : ''}${lastMessageText}`
89
+ const isChannelStarred = useChannelStar(channel)
86
90
 
87
91
  // Use the unread prop passed by Stream Chat (reactive and updates automatically)
88
92
  const unreadCount = unread ?? 0
@@ -118,6 +122,7 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
118
122
  name={participantName}
119
123
  image={participantImage}
120
124
  size={44}
125
+ starred={isChannelStarred}
121
126
  className="[&_.avatar-fallback]:group-hover:bg-[#eeeeee]"
122
127
  />
123
128
 
@@ -131,6 +136,9 @@ const CustomChannelPreview = React.memo<ChannelPreviewUIComponentProps>(
131
136
  isSelected ? 'text-primary' : 'text-charcoal'
132
137
  )}
133
138
  >
139
+ {isChannelStarred && (
140
+ <span className="sr-only">Starred conversation. </span>
141
+ )}
134
142
  {participantName}
135
143
  </h3>
136
144
  {lastMessageTime && (
@@ -1,19 +1,11 @@
1
1
  import {
2
2
  ArrowLeftIcon,
3
3
  DotsThreeIcon,
4
- FlagIcon,
5
- ProhibitInsetIcon,
6
- SignOutIcon,
7
- SpinnerGapIcon,
8
4
  StarIcon,
9
5
  } from '@phosphor-icons/react'
10
6
  import classNames from 'classnames'
11
- import React, { useState, useCallback, useRef, useEffect } from 'react'
12
- import {
13
- Channel as ChannelType,
14
- ChannelMemberResponse,
15
- Event,
16
- } from 'stream-chat'
7
+ import React, { useCallback, useRef } from 'react'
8
+ import { Channel as ChannelType } from 'stream-chat'
17
9
  import {
18
10
  Channel,
19
11
  Window,
@@ -24,12 +16,11 @@ import {
24
16
  MessageUIComponentProps,
25
17
  } from 'stream-chat-react'
26
18
 
27
- import { useMessagingContext } from '../providers/MessagingProvider'
19
+ import { useChannelStar } from '../hooks/useChannelStar'
28
20
  import type { ChannelViewProps } from '../types'
29
21
 
30
- import ActionButton from './ActionButton'
31
22
  import { Avatar } from './Avatar'
32
- import { CloseButton } from './CloseButton'
23
+ import { ChannelInfoDialog } from './ChannelInfoDialog'
33
24
  import { CustomDateSeparator } from './CustomDateSeparator'
34
25
  import { CustomMessage } from './CustomMessage'
35
26
  import { CustomMessageInput } from './CustomMessageInput'
@@ -37,17 +28,6 @@ import { CustomSystemMessage } from './CustomSystemMessage'
37
28
  import { ChannelEmptyState } from './MessagingShell/ChannelEmptyState'
38
29
  import { LoadingState } from './MessagingShell/LoadingState'
39
30
 
40
- // Custom user type with email and username
41
- type CustomUser = {
42
- email?: string
43
- username?: string
44
- }
45
-
46
- // Blocked user from Stream Chat API
47
- type BlockedUser = {
48
- blocked_user_id: string
49
- }
50
-
51
31
  const ICON_BTN_CLASS =
52
32
  'size-10 rounded-full bg-[#F1F0EE] hover:bg-[#E5E4E1] flex items-center justify-center transition-colors duration-150 focus-ring'
53
33
 
@@ -80,26 +60,7 @@ const CustomChannelHeader: React.FC<{
80
60
  const participantName =
81
61
  participant?.user?.name || participant?.user?.id || 'Unknown member'
82
62
  const participantImage = participant?.user?.image
83
-
84
- const [isStarred, setIsStarred] = useState(
85
- !!channel.state.membership?.pinned_at
86
- )
87
-
88
- useEffect(() => {
89
- const handleMemberUpdate = (event: Event) => {
90
- setIsStarred(
91
- event?.member
92
- ? !!event.member.pinned_at
93
- : !!channel.state.membership?.pinned_at
94
- )
95
- }
96
-
97
- channel.on('member.updated', handleMemberUpdate)
98
-
99
- return () => {
100
- channel.off('member.updated', handleMemberUpdate)
101
- }
102
- }, [channel])
63
+ const isStarred = useChannelStar(channel)
103
64
 
104
65
  const handleStarClick = async () => {
105
66
  try {
@@ -136,6 +97,7 @@ const CustomChannelHeader: React.FC<{
136
97
  id={participant?.user?.id || channel.id || 'unknown'}
137
98
  name={participantName}
138
99
  image={participantImage}
100
+ starred={isStarred}
139
101
  size={40}
140
102
  />
141
103
  <h1 className="text-xs font-medium text-black/90">
@@ -188,6 +150,7 @@ const CustomChannelHeader: React.FC<{
188
150
  id={participant?.user?.id || channel.id || 'unknown'}
189
151
  name={participantName}
190
152
  image={participantImage}
153
+ starred={isStarred}
191
154
  size={40}
192
155
  />
193
156
  <div className="min-w-0">
@@ -231,292 +194,6 @@ const CustomChannelHeader: React.FC<{
231
194
  )
232
195
  }
233
196
 
234
- /**
235
- * Channel info dialog (matching original implementation)
236
- */
237
- const ChannelInfoDialog: React.FC<{
238
- dialogRef: React.RefObject<HTMLDialogElement>
239
- onClose: () => void
240
- participant: ChannelMemberResponse | undefined
241
- channel: ChannelType
242
- followerStatusLabel?: string
243
- onLeaveConversation?: (channel: ChannelType) => void
244
- onBlockParticipant?: (participantId?: string) => void
245
- showDeleteConversation?: boolean
246
- onDeleteConversationClick?: () => void
247
- onBlockParticipantClick?: () => void
248
- onReportParticipantClick?: () => void
249
- customChannelActions?: React.ReactNode
250
- }> = ({
251
- dialogRef,
252
- onClose,
253
- participant,
254
- channel,
255
- followerStatusLabel,
256
- onLeaveConversation,
257
- onBlockParticipant,
258
- showDeleteConversation = true,
259
- onDeleteConversationClick,
260
- onBlockParticipantClick,
261
- onReportParticipantClick,
262
- customChannelActions,
263
- }) => {
264
- const { service, debug } = useMessagingContext()
265
- const [isParticipantBlocked, setIsParticipantBlocked] = useState(false)
266
- const [isLeaving, setIsLeaving] = useState(false)
267
- const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false)
268
-
269
- // Check if participant is blocked when participant changes
270
- const checkIsParticipantBlocked = useCallback(async () => {
271
- if (!service || !participant?.user?.id) return
272
-
273
- try {
274
- const blockedUsers = await service.getBlockedUsers()
275
- const isBlocked = blockedUsers.some(
276
- (user: BlockedUser) => user.blocked_user_id === participant?.user?.id
277
- )
278
- setIsParticipantBlocked(isBlocked)
279
- } catch (error) {
280
- console.error(
281
- '[ChannelInfoDialog] Failed to check blocked status:',
282
- error
283
- )
284
- }
285
- }, [service, participant?.user?.id])
286
-
287
- useEffect(() => {
288
- checkIsParticipantBlocked()
289
- }, [checkIsParticipantBlocked])
290
-
291
- const handleLeaveConversation = async () => {
292
- if (isLeaving) return
293
-
294
- // Fire analytics callback before action
295
- onDeleteConversationClick?.()
296
-
297
- if (debug) {
298
- console.log('[ChannelInfoDialog] Leave conversation', channel.cid)
299
- }
300
- setIsLeaving(true)
301
-
302
- try {
303
- const actingUserId = channel._client?.userID ?? null
304
- await channel.hide(actingUserId, false)
305
-
306
- if (onLeaveConversation) {
307
- await onLeaveConversation(channel)
308
- }
309
-
310
- onClose()
311
- } catch (error) {
312
- console.error('[ChannelInfoDialog] Failed to leave conversation', error)
313
- } finally {
314
- setIsLeaving(false)
315
- }
316
- }
317
-
318
- const handleBlockUser = async () => {
319
- if (isUpdatingBlockStatus || !service) return
320
-
321
- // Fire analytics callback before action
322
- onBlockParticipantClick?.()
323
-
324
- if (debug) {
325
- console.log('[ChannelInfoDialog] Block member', participant?.user?.id)
326
- }
327
- setIsUpdatingBlockStatus(true)
328
-
329
- try {
330
- await service.blockUser(participant?.user?.id)
331
-
332
- if (onBlockParticipant) {
333
- await onBlockParticipant(participant?.user?.id)
334
- }
335
-
336
- onClose()
337
- } catch (error) {
338
- console.error('[ChannelInfoDialog] Failed to block member', error)
339
- } finally {
340
- setIsUpdatingBlockStatus(false)
341
- }
342
- }
343
-
344
- const handleUnblockUser = async () => {
345
- if (isUpdatingBlockStatus || !service) return
346
-
347
- // Fire analytics callback before action
348
- onBlockParticipantClick?.()
349
-
350
- if (debug) {
351
- console.log('[ChannelInfoDialog] Unblock member', participant?.user?.id)
352
- }
353
- setIsUpdatingBlockStatus(true)
354
-
355
- try {
356
- await service.unBlockUser(participant?.user?.id)
357
-
358
- if (onBlockParticipant) {
359
- await onBlockParticipant(participant?.user?.id)
360
- }
361
-
362
- onClose()
363
- } catch (error) {
364
- console.error('[ChannelInfoDialog] Failed to unblock member', error)
365
- } finally {
366
- setIsUpdatingBlockStatus(false)
367
- }
368
- }
369
-
370
- const handleReportUser = () => {
371
- // Fire analytics callback before action
372
- onReportParticipantClick?.()
373
-
374
- onClose()
375
- window.open(
376
- 'https://linktr.ee/s/about/trust-center/report',
377
- '_blank',
378
- 'noopener,noreferrer'
379
- )
380
- }
381
-
382
- if (!participant) return null
383
-
384
- const participantName =
385
- participant.user?.name || participant.user?.id || 'Unknown member'
386
- const participantImage = participant.user?.image
387
- const participantEmail = (participant.user as CustomUser)?.email
388
- const participantUsername = (participant.user as CustomUser)?.username
389
- const participantSecondary = participantEmail
390
- ? participantEmail
391
- : participantUsername
392
- ? `linktr.ee/${participantUsername}`
393
- : undefined
394
- const participantId = participant.user?.id || 'unknown'
395
-
396
- return (
397
- // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
398
- <dialog
399
- ref={dialogRef}
400
- className="mes-dialog group"
401
- onClose={onClose}
402
- onClick={(e) => {
403
- if (e.target === dialogRef.current) {
404
- onClose()
405
- }
406
- }}
407
- >
408
- <div className="ml-auto flex h-full w-full flex-col bg-white shadow-none transition-shadow duration-200 group-open:shadow-max-elevation-light">
409
- <div className="flex items-center justify-between border-b border-sand px-4 py-3">
410
- <h2 className="text-base font-semibold text-charcoal">Chat info</h2>
411
- <CloseButton onClick={onClose} />
412
- </div>
413
-
414
- <div className="flex-1 px-2 overflow-y-auto w-full">
415
- <div
416
- className="flex flex-col items-center gap-3 self-stretch px-4 py-2 mt-6 rounded-lg border border-black/[0.04]"
417
- style={{ backgroundColor: '#FBFAF9' }}
418
- >
419
- <div className="flex items-center gap-3 w-full">
420
- <Avatar
421
- id={participantId}
422
- name={participantName}
423
- image={participantImage}
424
- size={88}
425
- shape="circle"
426
- />
427
- <div className="flex flex-col min-w-0 flex-1">
428
- <p className="truncate text-base font-semibold text-charcoal">
429
- {participantName}
430
- </p>
431
- {participantSecondary && (
432
- <p className="truncate text-sm text-[#00000055]">
433
- {participantSecondary}
434
- </p>
435
- )}
436
- {followerStatusLabel && (
437
- <span
438
- className="mt-1 rounded-full text-xs font-normal w-fit"
439
- style={{
440
- padding: '4px 8px',
441
- backgroundColor:
442
- followerStatusLabel === 'Subscribed to you'
443
- ? '#DCFCE7'
444
- : '#F5F5F4',
445
- color:
446
- followerStatusLabel === 'Subscribed to you'
447
- ? '#008236'
448
- : '#78716C',
449
- lineHeight: '133.333%',
450
- letterSpacing: '0.21px',
451
- }}
452
- >
453
- {followerStatusLabel}
454
- </span>
455
- )}
456
- </div>
457
- </div>
458
- </div>
459
-
460
- <ul className="flex flex-col gap-2 mt-2">
461
- {showDeleteConversation && (
462
- <li>
463
- <ActionButton
464
- onClick={handleLeaveConversation}
465
- disabled={isLeaving}
466
- aria-busy={isLeaving}
467
- >
468
- {isLeaving ? (
469
- <SpinnerGapIcon className="h-5 w-5 animate-spin" />
470
- ) : (
471
- <SignOutIcon className="h-5 w-5" />
472
- )}
473
- <span>Delete Conversation</span>
474
- </ActionButton>
475
- </li>
476
- )}
477
- <li>
478
- {isParticipantBlocked ? (
479
- <ActionButton
480
- onClick={handleUnblockUser}
481
- disabled={isUpdatingBlockStatus}
482
- aria-busy={isUpdatingBlockStatus}
483
- >
484
- {isUpdatingBlockStatus ? (
485
- <SpinnerGapIcon className="h-5 w-5 animate-spin" />
486
- ) : (
487
- <ProhibitInsetIcon className="h-5 w-5" />
488
- )}
489
- <span>Unblock</span>
490
- </ActionButton>
491
- ) : (
492
- <ActionButton
493
- onClick={handleBlockUser}
494
- disabled={isUpdatingBlockStatus}
495
- aria-busy={isUpdatingBlockStatus}
496
- >
497
- {isUpdatingBlockStatus ? (
498
- <SpinnerGapIcon className="h-5 w-5 animate-spin" />
499
- ) : (
500
- <ProhibitInsetIcon className="h-5 w-5" />
501
- )}
502
- <span>Block</span>
503
- </ActionButton>
504
- )}
505
- </li>
506
- <li>
507
- <ActionButton variant="danger" onClick={handleReportUser}>
508
- <FlagIcon className="h-5 w-5" />
509
- <span>Report</span>
510
- </ActionButton>
511
- </li>
512
- {customChannelActions}
513
- </ul>
514
- </div>
515
- </div>
516
- </dialog>
517
- )
518
- }
519
-
520
197
  /**
521
198
  * Inner component that has access to channel context
522
199
  */
@@ -535,6 +212,7 @@ const ChannelViewInner: React.FC<{
535
212
  showStarButton?: boolean
536
213
  chatbotVotingEnabled?: boolean
537
214
  renderChannelBanner?: () => React.ReactNode
215
+ customProfileContent?: React.ReactNode
538
216
  customChannelActions?: React.ReactNode
539
217
  renderMessage?: (
540
218
  messageNode: React.ReactElement,
@@ -554,6 +232,7 @@ const ChannelViewInner: React.FC<{
554
232
  showStarButton = false,
555
233
  chatbotVotingEnabled = false,
556
234
  renderChannelBanner,
235
+ customProfileContent,
557
236
  customChannelActions,
558
237
  renderMessage,
559
238
  }) => {
@@ -664,6 +343,7 @@ const ChannelViewInner: React.FC<{
664
343
  onDeleteConversationClick={onDeleteConversationClick}
665
344
  onBlockParticipantClick={onBlockParticipantClick}
666
345
  onReportParticipantClick={onReportParticipantClick}
346
+ customProfileContent={customProfileContent}
667
347
  customChannelActions={customChannelActions}
668
348
  />
669
349
  </>
@@ -694,6 +374,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
694
374
  showStarButton = false,
695
375
  chatbotVotingEnabled = false,
696
376
  renderChannelBanner,
377
+ customProfileContent,
697
378
  customChannelActions,
698
379
  renderMessage,
699
380
  }) => {
@@ -771,6 +452,7 @@ export const ChannelView = React.memo<ChannelViewProps>(
771
452
  showStarButton={showStarButton}
772
453
  chatbotVotingEnabled={chatbotVotingEnabled}
773
454
  renderChannelBanner={renderChannelBanner}
455
+ customProfileContent={customProfileContent}
774
456
  customChannelActions={customChannelActions}
775
457
  renderMessage={renderMessage}
776
458
  />
@@ -38,6 +38,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
38
38
  chatbotVotingEnabled = false,
39
39
  renderMessagePreview,
40
40
  renderChannelBanner,
41
+ customProfileContent,
41
42
  customChannelActions,
42
43
  renderMessage,
43
44
  }) => {
@@ -499,6 +500,7 @@ export const MessagingShell: React.FC<MessagingShellProps> = ({
499
500
  onMessageSent={onMessageSent}
500
501
  showStarButton={showStarButton}
501
502
  chatbotVotingEnabled={chatbotVotingEnabled}
503
+ customProfileContent={customProfileContent}
502
504
  customChannelActions={customChannelActions}
503
505
  renderMessage={renderMessage}
504
506
  />
@@ -0,0 +1,31 @@
1
+ import { useEffect, useState } from 'react'
2
+ import { Channel, Event } from 'stream-chat'
3
+
4
+ export const useChannelStar = (channel?: Channel) => {
5
+ const [isChannelStarred, setIsChannelStarred] = useState(
6
+ !!channel?.state?.membership?.pinned_at
7
+ )
8
+
9
+ useEffect(() => {
10
+ if (!channel) {
11
+ setIsChannelStarred(false)
12
+ return
13
+ }
14
+
15
+ setIsChannelStarred(!!channel.state.membership?.pinned_at)
16
+
17
+ const handleMemberUpdate = (event: Event) => {
18
+ setIsChannelStarred(
19
+ event?.member ? !!event.member.pinned_at : !!channel.state.membership?.pinned_at
20
+ )
21
+ }
22
+
23
+ channel.on('member.updated', handleMemberUpdate)
24
+
25
+ return () => {
26
+ channel.off('member.updated', handleMemberUpdate)
27
+ }
28
+ }, [channel])
29
+
30
+ return isChannelStarred
31
+ }
package/src/types.ts CHANGED
@@ -185,6 +185,16 @@ export interface ChannelViewProps {
185
185
  */
186
186
  renderChannelBanner?: () => React.ReactNode
187
187
 
188
+ /**
189
+ * Custom content rendered below the participant name and contact details
190
+ * in the channel info dialog profile card.
191
+ * Useful for badges (e.g. follower status), metadata, or any extra info.
192
+ *
193
+ * @example
194
+ * customProfileContent={<SubscriptionBadge isFollower={channel.data?.isFollower} />}
195
+ */
196
+ customProfileContent?: React.ReactNode
197
+
188
198
  /**
189
199
  * Custom actions rendered at the bottom of the channel info dialog
190
200
  * (below Delete Conversation, Block/Unblock, Report).
@@ -227,6 +237,7 @@ export type ChannelViewPassthroughProps = Pick<
227
237
  | 'showStarButton'
228
238
  | 'chatbotVotingEnabled'
229
239
  | 'renderChannelBanner'
240
+ | 'customProfileContent'
230
241
  | 'customChannelActions'
231
242
  | 'renderMessage'
232
243
  >