@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@linktr.ee/messaging-react",
3
- "version": "1.24.4",
3
+ "version": "1.25.0",
4
4
  "description": "React messaging components built on messaging-core for web applications",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -35,6 +35,13 @@ WithoutImage.args = {
35
35
  name: 'Bob Smith',
36
36
  }
37
37
 
38
+ export const Starred: StoryFn<ComponentProps> = Template.bind({})
39
+ Starred.args = {
40
+ id: 'user-9',
41
+ name: 'Kaiya Korsgaard',
42
+ starred: true,
43
+ }
44
+
38
45
  export const SmallSize: StoryFn<ComponentProps> = Template.bind({})
39
46
  SmallSize.args = {
40
47
  id: 'user-3',
@@ -1,3 +1,4 @@
1
+ import { StarIcon } from '@phosphor-icons/react'
1
2
  import classNames from 'classnames'
2
3
  import React from 'react'
3
4
 
@@ -9,6 +10,7 @@ export interface AvatarProps {
9
10
  image?: string
10
11
  size?: number
11
12
  className?: string
13
+ starred?: boolean
12
14
  shape?: 'squircle' | 'circle'
13
15
  }
14
16
 
@@ -20,6 +22,7 @@ export const Avatar: React.FC<AvatarProps> = ({
20
22
  image,
21
23
  size = 40,
22
24
  className,
25
+ starred = false,
23
26
  shape = 'squircle',
24
27
  }) => {
25
28
  const emoji = getAvatarEmoji(id)
@@ -43,30 +46,39 @@ export const Avatar: React.FC<AvatarProps> = ({
43
46
 
44
47
  return (
45
48
  <div
46
- className={classNames('flex-shrink-0 overflow-hidden', className)}
49
+ className={classNames('relative flex-shrink-0', className)}
47
50
  style={{
48
51
  width: `${size}px`,
49
52
  height: `${size}px`,
50
- ...borderStyle,
51
53
  }}
52
54
  >
53
- {image ? (
54
- <img
55
- src={image}
56
- alt=""
57
- className="h-full w-full object-cover aspect-square"
58
- />
59
- ) : (
55
+ {starred && (
60
56
  <div
61
57
  aria-hidden="true"
62
- className={classNames(
63
- 'avatar-fallback flex h-full w-full items-center justify-center font-semibold bg-[#E6E5E3] select-none transition-colors',
64
- fontSizeClass
65
- )}
58
+ className="absolute -left-1.5 -top-1.5 z-10 flex size-5 items-center justify-center rounded-full bg-white shadow-[0_0_0_1px_rgba(0,0,0,0.04),0_4px_8px_rgba(0,0,0,0.06)]"
66
59
  >
67
- {emoji}
60
+ <StarIcon className="size-3 text-yellow-600" weight="duotone" />
68
61
  </div>
69
62
  )}
63
+ <div className="h-full w-full overflow-hidden" style={borderStyle}>
64
+ {image ? (
65
+ <img
66
+ src={image}
67
+ alt=""
68
+ className="aspect-square h-full w-full object-cover"
69
+ />
70
+ ) : (
71
+ <div
72
+ aria-hidden="true"
73
+ className={classNames(
74
+ 'avatar-fallback flex h-full w-full items-center justify-center bg-[#E6E5E3] font-semibold select-none transition-colors',
75
+ fontSizeClass
76
+ )}
77
+ >
78
+ {emoji}
79
+ </div>
80
+ )}
81
+ </div>
70
82
  </div>
71
83
  )
72
84
  }
@@ -0,0 +1,259 @@
1
+ import React from 'react'
2
+ import type { Channel, ChannelMemberResponse } from 'stream-chat'
3
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
4
+
5
+ import { renderWithProviders, screen, userEvent, waitFor } from '../../test/utils'
6
+
7
+ import { ChannelInfoDialog } from './index'
8
+
9
+ vi.mock('../../providers/MessagingProvider', () => ({
10
+ useMessagingContext: () => ({
11
+ service: {
12
+ getBlockedUsers: vi.fn().mockResolvedValue([]),
13
+ blockUser: vi.fn().mockResolvedValue(undefined),
14
+ unBlockUser: vi.fn().mockResolvedValue(undefined),
15
+ },
16
+ debug: false,
17
+ }),
18
+ }))
19
+
20
+ vi.mock('../Avatar', () => ({
21
+ Avatar: ({ name }: { name: string }) => (
22
+ <div data-testid="avatar">{name}</div>
23
+ ),
24
+ }))
25
+
26
+ vi.mock('../CloseButton', () => ({
27
+ CloseButton: ({ onClick }: { onClick: () => void }) => (
28
+ <button data-testid="close-button" onClick={onClick} />
29
+ ),
30
+ }))
31
+
32
+ vi.mock('../ActionButton', () => ({
33
+ default: ({
34
+ children,
35
+ onClick,
36
+ }: {
37
+ children: React.ReactNode
38
+ onClick?: () => void
39
+ }) => (
40
+ <button data-testid="action-button" onClick={onClick}>
41
+ {children}
42
+ </button>
43
+ ),
44
+ }))
45
+
46
+ vi.mock('@phosphor-icons/react', () => ({
47
+ FlagIcon: () => <span data-testid="flag-icon" />,
48
+ ProhibitInsetIcon: () => <span data-testid="prohibit-icon" />,
49
+ SignOutIcon: () => <span data-testid="signout-icon" />,
50
+ SpinnerGapIcon: () => <span data-testid="spinner-icon" />,
51
+ }))
52
+
53
+ const createChannel = () =>
54
+ ({
55
+ id: 'channel-1',
56
+ cid: 'messaging:channel-1',
57
+ data: {},
58
+ _client: { userID: 'visitor-1' },
59
+ state: {
60
+ members: {},
61
+ membership: {},
62
+ messages: [],
63
+ },
64
+ hide: vi.fn(),
65
+ }) as unknown as Channel
66
+
67
+ const createParticipant = (
68
+ overrides: Partial<{ name: string; email: string; username: string }> = {}
69
+ ) =>
70
+ ({
71
+ user: {
72
+ id: 'linker-1',
73
+ name: overrides.name ?? 'Linker',
74
+ image: undefined,
75
+ email: overrides.email,
76
+ username: overrides.username,
77
+ },
78
+ role: 'member',
79
+ }) as unknown as ChannelMemberResponse
80
+
81
+ const defaultProps = () => ({
82
+ dialogRef: { current: document.createElement('dialog') },
83
+ onClose: vi.fn(),
84
+ channel: createChannel(),
85
+ participant: createParticipant(),
86
+ })
87
+
88
+ describe('ChannelInfoDialog', () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks()
91
+ })
92
+
93
+ it('renders participant name', () => {
94
+ renderWithProviders(
95
+ <ChannelInfoDialog {...defaultProps()} />
96
+ )
97
+
98
+ const nameEl = screen.getByText('Linker', { selector: 'p' })
99
+ expect(nameEl).toBeInTheDocument()
100
+ })
101
+
102
+ it('renders participant email as secondary info', () => {
103
+ renderWithProviders(
104
+ <ChannelInfoDialog
105
+ {...defaultProps()}
106
+ participant={createParticipant({ email: 'linker@example.com' })}
107
+ />
108
+ )
109
+
110
+ expect(screen.getByText('linker@example.com')).toBeInTheDocument()
111
+ })
112
+
113
+ it('renders participant username as secondary info when no email', () => {
114
+ renderWithProviders(
115
+ <ChannelInfoDialog
116
+ {...defaultProps()}
117
+ participant={createParticipant({ username: 'linkeruser' })}
118
+ />
119
+ )
120
+
121
+ expect(screen.getByText('linktr.ee/linkeruser')).toBeInTheDocument()
122
+ })
123
+
124
+ it('renders customProfileContent when provided', () => {
125
+ renderWithProviders(
126
+ <ChannelInfoDialog
127
+ {...defaultProps()}
128
+ customProfileContent={
129
+ <span data-testid="custom-profile">Subscriber Badge</span>
130
+ }
131
+ />
132
+ )
133
+
134
+ expect(screen.getByTestId('custom-profile')).toHaveTextContent(
135
+ 'Subscriber Badge'
136
+ )
137
+ })
138
+
139
+ it('renders customChannelActions when provided', () => {
140
+ renderWithProviders(
141
+ <ChannelInfoDialog
142
+ {...defaultProps()}
143
+ customChannelActions={
144
+ <li data-testid="custom-action">Custom Action</li>
145
+ }
146
+ />
147
+ )
148
+
149
+ expect(screen.getByTestId('custom-action')).toHaveTextContent(
150
+ 'Custom Action'
151
+ )
152
+ })
153
+
154
+ it('renders followerStatusLabel when provided', () => {
155
+ renderWithProviders(
156
+ <ChannelInfoDialog
157
+ {...defaultProps()}
158
+ followerStatusLabel="Subscribed to you"
159
+ />
160
+ )
161
+
162
+ expect(screen.getByText('Subscribed to you')).toBeInTheDocument()
163
+ })
164
+
165
+ it('hides followerStatusLabel when customProfileContent is provided', () => {
166
+ renderWithProviders(
167
+ <ChannelInfoDialog
168
+ {...defaultProps()}
169
+ followerStatusLabel="Subscribed to you"
170
+ customProfileContent={
171
+ <span data-testid="custom-profile">Custom Badge</span>
172
+ }
173
+ />
174
+ )
175
+
176
+ expect(
177
+ screen.queryByText('Subscribed to you')
178
+ ).not.toBeInTheDocument()
179
+ expect(screen.getByTestId('custom-profile')).toHaveTextContent(
180
+ 'Custom Badge'
181
+ )
182
+ })
183
+
184
+ it('shows Delete Conversation button when showDeleteConversation is true', () => {
185
+ renderWithProviders(
186
+ <ChannelInfoDialog {...defaultProps()} showDeleteConversation={true} />
187
+ )
188
+
189
+ expect(screen.getByText('Delete Conversation')).toBeInTheDocument()
190
+ })
191
+
192
+ it('hides Delete Conversation button when showDeleteConversation is false', () => {
193
+ renderWithProviders(
194
+ <ChannelInfoDialog {...defaultProps()} showDeleteConversation={false} />
195
+ )
196
+
197
+ expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
198
+ })
199
+
200
+ it('calls onDeleteConversationClick when Delete Conversation is clicked', async () => {
201
+ const onDeleteConversationClick = vi.fn()
202
+ const props = defaultProps()
203
+ renderWithProviders(
204
+ <ChannelInfoDialog
205
+ {...props}
206
+ onDeleteConversationClick={onDeleteConversationClick}
207
+ />
208
+ )
209
+
210
+ await userEvent.click(screen.getByText('Delete Conversation').closest('button')!)
211
+ await waitFor(() => {
212
+ expect(onDeleteConversationClick).toHaveBeenCalledOnce()
213
+ })
214
+ })
215
+
216
+ it('calls onBlockParticipantClick when Block is clicked', async () => {
217
+ const onBlockParticipantClick = vi.fn()
218
+ renderWithProviders(
219
+ <ChannelInfoDialog
220
+ {...defaultProps()}
221
+ onBlockParticipantClick={onBlockParticipantClick}
222
+ />
223
+ )
224
+
225
+ await userEvent.click(screen.getByText('Block').closest('button')!)
226
+ await waitFor(() => {
227
+ expect(onBlockParticipantClick).toHaveBeenCalledOnce()
228
+ })
229
+ })
230
+
231
+ it('calls onReportParticipantClick when Report is clicked', async () => {
232
+ const onReportParticipantClick = vi.fn()
233
+ const windowOpenSpy = vi.spyOn(window, 'open').mockImplementation(() => null)
234
+
235
+ renderWithProviders(
236
+ <ChannelInfoDialog
237
+ {...defaultProps()}
238
+ onReportParticipantClick={onReportParticipantClick}
239
+ />
240
+ )
241
+
242
+ await userEvent.click(screen.getByText('Report').closest('button')!)
243
+ await waitFor(() => {
244
+ expect(onReportParticipantClick).toHaveBeenCalledOnce()
245
+ })
246
+ windowOpenSpy.mockRestore()
247
+ })
248
+
249
+ it('returns null when participant is undefined', () => {
250
+ const { container } = renderWithProviders(
251
+ <ChannelInfoDialog
252
+ {...defaultProps()}
253
+ participant={undefined}
254
+ />
255
+ )
256
+
257
+ expect(container.querySelector('dialog')).not.toBeInTheDocument()
258
+ })
259
+ })
@@ -0,0 +1,316 @@
1
+ import {
2
+ FlagIcon,
3
+ ProhibitInsetIcon,
4
+ SignOutIcon,
5
+ SpinnerGapIcon,
6
+ } from '@phosphor-icons/react'
7
+ import React, { useState, useCallback, useEffect } from 'react'
8
+ import type { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat'
9
+
10
+ import { useMessagingContext } from '../../providers/MessagingProvider'
11
+ import ActionButton from '../ActionButton'
12
+ import { Avatar } from '../Avatar'
13
+ import { CloseButton } from '../CloseButton'
14
+
15
+ // Custom user type with email and username
16
+ type CustomUser = {
17
+ email?: string
18
+ username?: string
19
+ }
20
+
21
+ // Blocked user from Stream Chat API
22
+ type BlockedUser = {
23
+ blocked_user_id: string
24
+ }
25
+
26
+ export interface ChannelInfoDialogProps {
27
+ dialogRef: React.RefObject<HTMLDialogElement>
28
+ onClose: () => void
29
+ participant: ChannelMemberResponse | undefined
30
+ channel: ChannelType
31
+ followerStatusLabel?: string
32
+ onLeaveConversation?: (channel: ChannelType) => void
33
+ onBlockParticipant?: (participantId?: string) => void
34
+ showDeleteConversation?: boolean
35
+ onDeleteConversationClick?: () => void
36
+ onBlockParticipantClick?: () => void
37
+ onReportParticipantClick?: () => void
38
+ customProfileContent?: React.ReactNode
39
+ customChannelActions?: React.ReactNode
40
+ }
41
+
42
+ /**
43
+ * Channel info dialog (matching original implementation)
44
+ */
45
+ export const ChannelInfoDialog: React.FC<ChannelInfoDialogProps> = ({
46
+ dialogRef,
47
+ onClose,
48
+ participant,
49
+ channel,
50
+ followerStatusLabel,
51
+ onLeaveConversation,
52
+ onBlockParticipant,
53
+ showDeleteConversation = true,
54
+ onDeleteConversationClick,
55
+ onBlockParticipantClick,
56
+ onReportParticipantClick,
57
+ customProfileContent,
58
+ customChannelActions,
59
+ }) => {
60
+ const { service, debug } = useMessagingContext()
61
+ const [isParticipantBlocked, setIsParticipantBlocked] = useState(false)
62
+ const [isLeaving, setIsLeaving] = useState(false)
63
+ const [isUpdatingBlockStatus, setIsUpdatingBlockStatus] = useState(false)
64
+
65
+ // Check if participant is blocked when participant changes
66
+ const checkIsParticipantBlocked = useCallback(async () => {
67
+ if (!service || !participant?.user?.id) return
68
+
69
+ try {
70
+ const blockedUsers = await service.getBlockedUsers()
71
+ const isBlocked = blockedUsers.some(
72
+ (user: BlockedUser) => user.blocked_user_id === participant?.user?.id
73
+ )
74
+ setIsParticipantBlocked(isBlocked)
75
+ } catch (error) {
76
+ console.error(
77
+ '[ChannelInfoDialog] Failed to check blocked status:',
78
+ error
79
+ )
80
+ }
81
+ }, [service, participant?.user?.id])
82
+
83
+ useEffect(() => {
84
+ checkIsParticipantBlocked()
85
+ }, [checkIsParticipantBlocked])
86
+
87
+ const handleLeaveConversation = async () => {
88
+ if (isLeaving) return
89
+
90
+ // Fire analytics callback before action
91
+ onDeleteConversationClick?.()
92
+
93
+ if (debug) {
94
+ console.log('[ChannelInfoDialog] Leave conversation', channel.cid)
95
+ }
96
+ setIsLeaving(true)
97
+
98
+ try {
99
+ const actingUserId = channel._client?.userID ?? null
100
+ await channel.hide(actingUserId, false)
101
+
102
+ if (onLeaveConversation) {
103
+ await onLeaveConversation(channel)
104
+ }
105
+
106
+ onClose()
107
+ } catch (error) {
108
+ console.error('[ChannelInfoDialog] Failed to leave conversation', error)
109
+ } finally {
110
+ setIsLeaving(false)
111
+ }
112
+ }
113
+
114
+ const handleBlockUser = async () => {
115
+ if (isUpdatingBlockStatus || !service) return
116
+
117
+ // Fire analytics callback before action
118
+ onBlockParticipantClick?.()
119
+
120
+ if (debug) {
121
+ console.log('[ChannelInfoDialog] Block member', participant?.user?.id)
122
+ }
123
+ setIsUpdatingBlockStatus(true)
124
+
125
+ try {
126
+ await service.blockUser(participant?.user?.id)
127
+
128
+ if (onBlockParticipant) {
129
+ await onBlockParticipant(participant?.user?.id)
130
+ }
131
+
132
+ onClose()
133
+ } catch (error) {
134
+ console.error('[ChannelInfoDialog] Failed to block member', error)
135
+ } finally {
136
+ setIsUpdatingBlockStatus(false)
137
+ }
138
+ }
139
+
140
+ const handleUnblockUser = async () => {
141
+ if (isUpdatingBlockStatus || !service) return
142
+
143
+ // Fire analytics callback before action
144
+ onBlockParticipantClick?.()
145
+
146
+ if (debug) {
147
+ console.log('[ChannelInfoDialog] Unblock member', participant?.user?.id)
148
+ }
149
+ setIsUpdatingBlockStatus(true)
150
+
151
+ try {
152
+ await service.unBlockUser(participant?.user?.id)
153
+
154
+ if (onBlockParticipant) {
155
+ await onBlockParticipant(participant?.user?.id)
156
+ }
157
+
158
+ onClose()
159
+ } catch (error) {
160
+ console.error('[ChannelInfoDialog] Failed to unblock member', error)
161
+ } finally {
162
+ setIsUpdatingBlockStatus(false)
163
+ }
164
+ }
165
+
166
+ const handleReportUser = () => {
167
+ // Fire analytics callback before action
168
+ onReportParticipantClick?.()
169
+
170
+ onClose()
171
+ window.open(
172
+ 'https://linktr.ee/s/about/trust-center/report',
173
+ '_blank',
174
+ 'noopener,noreferrer'
175
+ )
176
+ }
177
+
178
+ if (!participant) return null
179
+
180
+ const participantName =
181
+ participant.user?.name || participant.user?.id || 'Unknown member'
182
+ const participantImage = participant.user?.image
183
+ const participantEmail = (participant.user as CustomUser)?.email
184
+ const participantUsername = (participant.user as CustomUser)?.username
185
+ const participantSecondary = participantEmail
186
+ ? participantEmail
187
+ : participantUsername
188
+ ? `linktr.ee/${participantUsername}`
189
+ : undefined
190
+ const participantId = participant.user?.id || 'unknown'
191
+
192
+ return (
193
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions
194
+ <dialog
195
+ ref={dialogRef}
196
+ className="mes-dialog group"
197
+ onClose={onClose}
198
+ onClick={(e) => {
199
+ if (e.target === dialogRef.current) {
200
+ onClose()
201
+ }
202
+ }}
203
+ >
204
+ <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">
205
+ <div className="flex items-center justify-between border-b border-sand px-4 py-3">
206
+ <h2 className="text-base font-semibold text-charcoal">Chat info</h2>
207
+ <CloseButton onClick={onClose} />
208
+ </div>
209
+
210
+ <div className="flex-1 px-2 overflow-y-auto w-full">
211
+ <div
212
+ className="flex flex-col items-center gap-3 self-stretch px-4 py-2 mt-6 rounded-lg border border-black/[0.04]"
213
+ style={{ backgroundColor: '#FBFAF9' }}
214
+ >
215
+ <div className="flex items-center gap-3 w-full">
216
+ <Avatar
217
+ id={participantId}
218
+ name={participantName}
219
+ image={participantImage}
220
+ size={88}
221
+ shape="circle"
222
+ />
223
+ <div className="flex flex-col min-w-0 flex-1">
224
+ <p className="truncate text-base font-semibold text-charcoal">
225
+ {participantName}
226
+ </p>
227
+ {participantSecondary && (
228
+ <p className="truncate text-sm text-[#00000055]">
229
+ {participantSecondary}
230
+ </p>
231
+ )}
232
+ {/* Deprecated: use customProfileContent instead. followerStatusLabel will be removed in a future release. */}
233
+ {followerStatusLabel && !customProfileContent && (
234
+ <span
235
+ className="mt-1 rounded-full text-xs font-normal w-fit"
236
+ style={{
237
+ padding: '4px 8px',
238
+ backgroundColor:
239
+ followerStatusLabel === 'Subscribed to you'
240
+ ? '#DCFCE7'
241
+ : '#F5F5F4',
242
+ color:
243
+ followerStatusLabel === 'Subscribed to you'
244
+ ? '#008236'
245
+ : '#78716C',
246
+ lineHeight: '133.333%',
247
+ letterSpacing: '0.21px',
248
+ }}
249
+ >
250
+ {followerStatusLabel}
251
+ </span>
252
+ )}
253
+ {customProfileContent}
254
+ </div>
255
+ </div>
256
+ </div>
257
+
258
+ <ul className="flex flex-col gap-2 mt-2">
259
+ {showDeleteConversation && (
260
+ <li>
261
+ <ActionButton
262
+ onClick={handleLeaveConversation}
263
+ disabled={isLeaving}
264
+ aria-busy={isLeaving}
265
+ >
266
+ {isLeaving ? (
267
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
268
+ ) : (
269
+ <SignOutIcon className="h-5 w-5" />
270
+ )}
271
+ <span>Delete Conversation</span>
272
+ </ActionButton>
273
+ </li>
274
+ )}
275
+ <li>
276
+ {isParticipantBlocked ? (
277
+ <ActionButton
278
+ onClick={handleUnblockUser}
279
+ disabled={isUpdatingBlockStatus}
280
+ aria-busy={isUpdatingBlockStatus}
281
+ >
282
+ {isUpdatingBlockStatus ? (
283
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
284
+ ) : (
285
+ <ProhibitInsetIcon className="h-5 w-5" />
286
+ )}
287
+ <span>Unblock</span>
288
+ </ActionButton>
289
+ ) : (
290
+ <ActionButton
291
+ onClick={handleBlockUser}
292
+ disabled={isUpdatingBlockStatus}
293
+ aria-busy={isUpdatingBlockStatus}
294
+ >
295
+ {isUpdatingBlockStatus ? (
296
+ <SpinnerGapIcon className="h-5 w-5 animate-spin" />
297
+ ) : (
298
+ <ProhibitInsetIcon className="h-5 w-5" />
299
+ )}
300
+ <span>Block</span>
301
+ </ActionButton>
302
+ )}
303
+ </li>
304
+ <li>
305
+ <ActionButton variant="danger" onClick={handleReportUser}>
306
+ <FlagIcon className="h-5 w-5" />
307
+ <span>Report</span>
308
+ </ActionButton>
309
+ </li>
310
+ {customChannelActions}
311
+ </ul>
312
+ </div>
313
+ </div>
314
+ </dialog>
315
+ )
316
+ }