@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/dist/index.d.ts +11 -1
- package/dist/index.js +860 -827
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/components/Avatar/Avatar.stories.tsx +7 -0
- package/src/components/Avatar/index.tsx +26 -14
- package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +259 -0
- package/src/components/ChannelInfoDialog/index.tsx +316 -0
- package/src/components/ChannelList/CustomChannelPreview.test.tsx +57 -5
- package/src/components/ChannelList/CustomChannelPreview.tsx +8 -0
- package/src/components/ChannelView.tsx +12 -330
- package/src/components/MessagingShell/index.tsx +2 -0
- package/src/hooks/useChannelStar.ts +31 -0
- package/src/types.ts +11 -0
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
{
|
|
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=
|
|
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
|
-
|
|
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
|
+
}
|