@linktr.ee/messaging-react 3.2.0 → 3.3.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/{Card-bdnjL_4d.js → Card-BAc2cgtn.js} +3 -3
- package/dist/{Card-bdnjL_4d.js.map → Card-BAc2cgtn.js.map} +1 -1
- package/dist/{Card-CO089n1e.cjs → Card-Cn1cBVnr.cjs} +2 -2
- package/dist/{Card-CO089n1e.cjs.map → Card-Cn1cBVnr.cjs.map} +1 -1
- package/dist/{Card-B5TCecD6.cjs → Card-DAyszUxa.cjs} +2 -2
- package/dist/{Card-B5TCecD6.cjs.map → Card-DAyszUxa.cjs.map} +1 -1
- package/dist/{Card-DQYLHbDI.js → Card-D_2VQScd.js} +2 -2
- package/dist/{Card-DQYLHbDI.js.map → Card-D_2VQScd.js.map} +1 -1
- package/dist/{Card-DTaHgygz.js → Card-D_G8133I.js} +2 -2
- package/dist/{Card-DTaHgygz.js.map → Card-D_G8133I.js.map} +1 -1
- package/dist/{Card-aO1qZWDU.cjs → Card-gYxPXe_W.cjs} +2 -2
- package/dist/{Card-aO1qZWDU.cjs.map → Card-gYxPXe_W.cjs.map} +1 -1
- package/dist/{LockedThumbnail-nsFA3DjA.js → LockedThumbnail-C7tWpOQr.js} +2 -2
- package/dist/{LockedThumbnail-nsFA3DjA.js.map → LockedThumbnail-C7tWpOQr.js.map} +1 -1
- package/dist/{LockedThumbnail-CWVybsBb.cjs → LockedThumbnail-DtOTZl3l.cjs} +2 -2
- package/dist/{LockedThumbnail-CWVybsBb.cjs.map → LockedThumbnail-DtOTZl3l.cjs.map} +1 -1
- package/dist/{index-DJKFVBkP.js → index-C_NFzAB9.js} +1356 -1382
- package/dist/index-C_NFzAB9.js.map +1 -0
- package/dist/index-_Se6ovQm.cjs +2 -0
- package/dist/index-_Se6ovQm.cjs.map +1 -0
- package/dist/index.cjs +1 -1
- package/dist/index.d.ts +20 -15
- package/dist/index.js +1 -1
- package/package.json +3 -3
- package/src/components/ChannelActionsMenu/ChannelActionsMenu.test.tsx +305 -0
- package/src/components/ChannelActionsMenu/index.tsx +221 -0
- package/src/components/ChannelView.stories.tsx +3 -73
- package/src/components/ChannelView.test.tsx +30 -57
- package/src/components/ChannelView.tsx +66 -115
- package/src/hooks/useChannelModerationActions.ts +227 -0
- package/src/types.ts +20 -15
- package/dist/index-BO2VfA-M.cjs +0 -2
- package/dist/index-BO2VfA-M.cjs.map +0 -1
- package/dist/index-DJKFVBkP.js.map +0 -1
- package/src/components/ChannelInfoDialog/ChannelInfoDialog.test.tsx +0 -333
- package/src/components/ChannelInfoDialog/index.tsx +0 -336
package/dist/index.cjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-
|
|
1
|
+
"use strict";Object.defineProperty(exports,Symbol.toStringTag,{value:"Module"});const e=require("./index-_Se6ovQm.cjs");exports.ActionButton=e.ActionButton;exports.Avatar=e.Avatar;exports.ChannelEmptyState=e.ChannelEmptyState;exports.ChannelList=e.ChannelList;exports.ChannelView=e.ChannelView;exports.CustomMessageProvider=e.CustomMessageProvider;exports.FaqList=e.FaqList;exports.FaqListItem=e.FaqListItem;exports.LinkAttachment=e.LinkAttachment;exports.LockedAttachment=e.LockedAttachment;exports.MediaMessage=e.MediaMessage;exports.MessageAttachment=e.MessageAttachment;exports.MessageVoteButtons=e.MessageVoteButtons;exports.MessagingProvider=e.MessagingProvider;exports.MessagingShell=e.MessagingShell;exports.buildCompactMetaLabel=e.buildCompactMetaLabel;exports.formatFileSize=e.formatFileSize;exports.formatRelativeTime=e.formatRelativeTime;exports.getFileExtensionLabel=e.getFileExtensionLabel;exports.getMessageDisplayText=e.getMessageDisplayText;exports.isLinkAttachment=e.isLinkAttachment;exports.isUuidLike=e.isUuidLike;exports.messageAttachmentGroupPositionFromStream=e.bubbleGroupPositionFromStream;exports.normalizeLanguageCode=e.normalizeLanguageCode;exports.resolveLinkAttachment=e.resolveLinkAttachment;exports.resolveMediaFromMessage=e.resolveMediaFromMessage;exports.resolveParticipantDisplayName=e.resolveParticipantDisplayName;exports.useCustomMessage=e.useCustomMessage;exports.useMessageVote=e.useMessageVote;exports.useMessaging=e.useMessaging;
|
|
2
2
|
//# sourceMappingURL=index.cjs.map
|
package/dist/index.d.ts
CHANGED
|
@@ -194,9 +194,11 @@ export declare interface ChannelViewProps {
|
|
|
194
194
|
*/
|
|
195
195
|
showReportParticipant?: boolean;
|
|
196
196
|
/**
|
|
197
|
-
*
|
|
198
|
-
*
|
|
199
|
-
*
|
|
197
|
+
* @deprecated Not currently rendered. The channel info sidebar was removed
|
|
198
|
+
* in favour of the actions popover; this prop is retained for API
|
|
199
|
+
* compatibility and will be wired up again when the replacement profile
|
|
200
|
+
* surface lands. Previously toggled the subscription/follower-status label
|
|
201
|
+
* in the channel info dialog profile card.
|
|
200
202
|
*/
|
|
201
203
|
showFollowerStatus?: boolean;
|
|
202
204
|
/**
|
|
@@ -249,11 +251,15 @@ export declare interface ChannelViewProps {
|
|
|
249
251
|
*/
|
|
250
252
|
showStarButton?: boolean;
|
|
251
253
|
/**
|
|
252
|
-
* Show the channel
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
*
|
|
254
|
+
* Show the channel actions menu (the `...` popover) in the header, which
|
|
255
|
+
* exposes the block/report/delete moderation actions. Defaults to true.
|
|
256
|
+
* Set false for surfaces that should not expose those actions — e.g.
|
|
257
|
+
* anonymous visitor chat, where the visitor has no authenticated identity
|
|
258
|
+
* to act on.
|
|
259
|
+
*
|
|
260
|
+
* Note: the channel info sidebar was removed in favour of the actions
|
|
261
|
+
* popover, so this no longer mounts a profile dialog or renders a clickable
|
|
262
|
+
* participant name; it now solely gates the actions menu.
|
|
257
263
|
*/
|
|
258
264
|
showChannelInfo?: boolean;
|
|
259
265
|
/**
|
|
@@ -275,16 +281,15 @@ export declare interface ChannelViewProps {
|
|
|
275
281
|
*/
|
|
276
282
|
renderChannelBanner?: () => React.ReactNode;
|
|
277
283
|
/**
|
|
278
|
-
*
|
|
279
|
-
* in the
|
|
280
|
-
*
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* customProfileContent={<SubscriptionBadge isFollower={channel.data?.isFollower} />}
|
|
284
|
+
* @deprecated Not currently rendered. The channel info sidebar was removed
|
|
285
|
+
* in favour of the actions popover; this prop is retained for API
|
|
286
|
+
* compatibility and will be wired up again when the replacement profile
|
|
287
|
+
* surface lands. Previously rendered custom content (badges, metadata)
|
|
288
|
+
* below the participant name in the channel info dialog profile card.
|
|
284
289
|
*/
|
|
285
290
|
customProfileContent?: React.ReactNode;
|
|
286
291
|
/**
|
|
287
|
-
* Custom actions rendered at the bottom of the channel
|
|
292
|
+
* Custom actions rendered at the bottom of the channel actions popover
|
|
288
293
|
* (below Delete Conversation, Block/Unblock, Report).
|
|
289
294
|
* Pass one or more <li> elements so they match the list styling.
|
|
290
295
|
* Use the exported ActionButton for consistent styling.
|
package/dist/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-
|
|
1
|
+
import { a as e, b as t, C as i, c as n, d as o, e as m, F as g, f as l, L as r, h as M, M as u, i as L, j as c, k as h, l as d, m as p, n as v, o as A, p as C, q as F, s as k, t as b, u as f, v as x, w as y, x as P, y as S, z as q, B as z, D as B } from "./index-C_NFzAB9.js";
|
|
2
2
|
export {
|
|
3
3
|
e as ActionButton,
|
|
4
4
|
t as Avatar,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linktr.ee/messaging-react",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.0",
|
|
4
4
|
"description": "React messaging components built on messaging-core for web applications",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.cjs",
|
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
"test:ci": "vitest run",
|
|
29
29
|
"test:ui": "vitest --ui",
|
|
30
30
|
"test:coverage": "vitest --coverage",
|
|
31
|
-
"lint": "
|
|
32
|
-
"lint:fix": "
|
|
31
|
+
"lint": "npx --no-install eslint . --ext .ts,.tsx",
|
|
32
|
+
"lint:fix": "npx --no-install eslint . --ext .ts,.tsx --fix",
|
|
33
33
|
"verify": "yarn type-check && yarn lint && yarn test:ci && yarn build",
|
|
34
34
|
"storybook": "storybook dev -p 6006",
|
|
35
35
|
"storybook:build": "storybook build"
|
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import type { Channel, ChannelMemberResponse } from 'stream-chat'
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
renderWithProviders,
|
|
7
|
+
screen,
|
|
8
|
+
userEvent,
|
|
9
|
+
waitFor,
|
|
10
|
+
} from '../../test/utils'
|
|
11
|
+
|
|
12
|
+
import { ChannelActionsMenu } from './index'
|
|
13
|
+
|
|
14
|
+
const { getBlockedUsersMock, blockUserMock, unBlockUserMock } = vi.hoisted(
|
|
15
|
+
() => ({
|
|
16
|
+
getBlockedUsersMock: vi.fn().mockResolvedValue([]),
|
|
17
|
+
blockUserMock: vi.fn().mockResolvedValue(undefined),
|
|
18
|
+
unBlockUserMock: vi.fn().mockResolvedValue(undefined),
|
|
19
|
+
})
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
vi.mock('../../providers/MessagingProvider', () => ({
|
|
23
|
+
useMessagingContext: () => ({
|
|
24
|
+
service: {
|
|
25
|
+
getBlockedUsers: getBlockedUsersMock,
|
|
26
|
+
blockUser: blockUserMock,
|
|
27
|
+
unBlockUser: unBlockUserMock,
|
|
28
|
+
},
|
|
29
|
+
debug: false,
|
|
30
|
+
}),
|
|
31
|
+
}))
|
|
32
|
+
|
|
33
|
+
vi.mock('../ActionButton', () => ({
|
|
34
|
+
default: ({
|
|
35
|
+
children,
|
|
36
|
+
onClick,
|
|
37
|
+
disabled,
|
|
38
|
+
}: {
|
|
39
|
+
children: React.ReactNode
|
|
40
|
+
onClick?: () => void
|
|
41
|
+
disabled?: boolean
|
|
42
|
+
}) => (
|
|
43
|
+
<button data-testid="action-button" onClick={onClick} disabled={disabled}>
|
|
44
|
+
{children}
|
|
45
|
+
</button>
|
|
46
|
+
),
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
vi.mock('@phosphor-icons/react', () => ({
|
|
50
|
+
DotsThreeIcon: () => <span data-testid="dots-icon" />,
|
|
51
|
+
FlagIcon: () => <span data-testid="flag-icon" />,
|
|
52
|
+
ProhibitInsetIcon: () => <span data-testid="prohibit-icon" />,
|
|
53
|
+
SignOutIcon: () => <span data-testid="signout-icon" />,
|
|
54
|
+
SpinnerGapIcon: () => <span data-testid="spinner-icon" />,
|
|
55
|
+
}))
|
|
56
|
+
|
|
57
|
+
const createChannel = () =>
|
|
58
|
+
({
|
|
59
|
+
id: 'channel-1',
|
|
60
|
+
cid: 'messaging:channel-1',
|
|
61
|
+
data: {},
|
|
62
|
+
_client: { userID: 'visitor-1' },
|
|
63
|
+
state: {
|
|
64
|
+
members: {},
|
|
65
|
+
membership: {},
|
|
66
|
+
messages: [],
|
|
67
|
+
},
|
|
68
|
+
hide: vi.fn(),
|
|
69
|
+
}) as unknown as Channel
|
|
70
|
+
|
|
71
|
+
const createParticipant = () =>
|
|
72
|
+
({
|
|
73
|
+
user: {
|
|
74
|
+
id: 'linker-1',
|
|
75
|
+
name: 'Linker',
|
|
76
|
+
},
|
|
77
|
+
role: 'member',
|
|
78
|
+
}) as unknown as ChannelMemberResponse
|
|
79
|
+
|
|
80
|
+
const defaultProps = () => ({
|
|
81
|
+
channel: createChannel(),
|
|
82
|
+
participant: createParticipant(),
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const openMenu = async () =>
|
|
86
|
+
userEvent.click(screen.getByRole('button', { name: 'More options' }))
|
|
87
|
+
|
|
88
|
+
describe('ChannelActionsMenu', () => {
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
vi.clearAllMocks()
|
|
91
|
+
getBlockedUsersMock.mockResolvedValue([])
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders the trigger but keeps options hidden until opened', () => {
|
|
95
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
96
|
+
|
|
97
|
+
expect(
|
|
98
|
+
screen.getByRole('button', { name: 'More options' })
|
|
99
|
+
).toBeInTheDocument()
|
|
100
|
+
expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
|
|
101
|
+
expect(screen.queryByText('Block')).not.toBeInTheDocument()
|
|
102
|
+
expect(screen.queryByText('Report')).not.toBeInTheDocument()
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('reflects open state via aria-expanded and shows the options inline', async () => {
|
|
106
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
107
|
+
|
|
108
|
+
const trigger = screen.getByRole('button', { name: 'More options' })
|
|
109
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'false')
|
|
110
|
+
|
|
111
|
+
await openMenu()
|
|
112
|
+
|
|
113
|
+
expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
114
|
+
expect(screen.getByText('Delete Conversation')).toBeInTheDocument()
|
|
115
|
+
expect(screen.getByText('Block')).toBeInTheDocument()
|
|
116
|
+
expect(screen.getByText('Report')).toBeInTheDocument()
|
|
117
|
+
// Each option renders its icon inline.
|
|
118
|
+
expect(screen.getByTestId('signout-icon')).toBeInTheDocument()
|
|
119
|
+
expect(screen.getByTestId('prohibit-icon')).toBeInTheDocument()
|
|
120
|
+
expect(screen.getByTestId('flag-icon')).toBeInTheDocument()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('closes the popover when Escape is pressed', async () => {
|
|
124
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
125
|
+
|
|
126
|
+
await openMenu()
|
|
127
|
+
expect(screen.getByText('Report')).toBeInTheDocument()
|
|
128
|
+
|
|
129
|
+
await userEvent.keyboard('{Escape}')
|
|
130
|
+
|
|
131
|
+
await waitFor(() => {
|
|
132
|
+
expect(screen.queryByText('Report')).not.toBeInTheDocument()
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
it('closes the popover when clicking outside', async () => {
|
|
137
|
+
renderWithProviders(
|
|
138
|
+
<div>
|
|
139
|
+
<span data-testid="outside">outside</span>
|
|
140
|
+
<ChannelActionsMenu {...defaultProps()} />
|
|
141
|
+
</div>
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
await openMenu()
|
|
145
|
+
expect(screen.getByText('Report')).toBeInTheDocument()
|
|
146
|
+
|
|
147
|
+
await userEvent.click(screen.getByTestId('outside'))
|
|
148
|
+
|
|
149
|
+
await waitFor(() => {
|
|
150
|
+
expect(screen.queryByText('Report')).not.toBeInTheDocument()
|
|
151
|
+
})
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('calls onDeleteConversationClick and closes after deleting', async () => {
|
|
155
|
+
const onDeleteConversationClick = vi.fn()
|
|
156
|
+
renderWithProviders(
|
|
157
|
+
<ChannelActionsMenu
|
|
158
|
+
{...defaultProps()}
|
|
159
|
+
onDeleteConversationClick={onDeleteConversationClick}
|
|
160
|
+
/>
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
await openMenu()
|
|
164
|
+
await userEvent.click(screen.getByText('Delete Conversation'))
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
expect(onDeleteConversationClick).toHaveBeenCalledOnce()
|
|
168
|
+
})
|
|
169
|
+
await waitFor(() => {
|
|
170
|
+
expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('calls onBlockParticipantClick and blocks the user', async () => {
|
|
175
|
+
const onBlockParticipantClick = vi.fn()
|
|
176
|
+
renderWithProviders(
|
|
177
|
+
<ChannelActionsMenu
|
|
178
|
+
{...defaultProps()}
|
|
179
|
+
onBlockParticipantClick={onBlockParticipantClick}
|
|
180
|
+
/>
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
await openMenu()
|
|
184
|
+
await userEvent.click(screen.getByText('Block'))
|
|
185
|
+
|
|
186
|
+
await waitFor(() => {
|
|
187
|
+
expect(onBlockParticipantClick).toHaveBeenCalledOnce()
|
|
188
|
+
expect(blockUserMock).toHaveBeenCalledWith('linker-1')
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('shows Unblock when the participant is already blocked', async () => {
|
|
193
|
+
getBlockedUsersMock.mockResolvedValue([{ blocked_user_id: 'linker-1' }])
|
|
194
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
195
|
+
|
|
196
|
+
await openMenu()
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(screen.getByText('Unblock')).toBeInTheDocument()
|
|
200
|
+
})
|
|
201
|
+
expect(screen.queryByText('Block')).not.toBeInTheDocument()
|
|
202
|
+
})
|
|
203
|
+
|
|
204
|
+
it('disables the block action until the blocked-status lookup resolves', async () => {
|
|
205
|
+
let resolveBlockedUsers: (
|
|
206
|
+
value: Array<{ blocked_user_id: string }>
|
|
207
|
+
) => void = () => {}
|
|
208
|
+
getBlockedUsersMock.mockImplementation(
|
|
209
|
+
() =>
|
|
210
|
+
new Promise((resolve) => {
|
|
211
|
+
resolveBlockedUsers = resolve
|
|
212
|
+
})
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
216
|
+
await openMenu()
|
|
217
|
+
|
|
218
|
+
// While the lookup is pending the action is disabled so a premature click
|
|
219
|
+
// can't act on a stale block state.
|
|
220
|
+
const pendingBlock = screen.getByText('Block').closest('button')
|
|
221
|
+
expect(pendingBlock).toBeDisabled()
|
|
222
|
+
await userEvent.click(pendingBlock as HTMLElement)
|
|
223
|
+
expect(blockUserMock).not.toHaveBeenCalled()
|
|
224
|
+
|
|
225
|
+
// Once the lookup resolves (already blocked) the Unblock action is offered.
|
|
226
|
+
resolveBlockedUsers([{ blocked_user_id: 'linker-1' }])
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(screen.getByText('Unblock')).toBeInTheDocument()
|
|
229
|
+
})
|
|
230
|
+
})
|
|
231
|
+
|
|
232
|
+
it('calls onReportParticipantClick and opens the report page', async () => {
|
|
233
|
+
const onReportParticipantClick = vi.fn()
|
|
234
|
+
const windowOpenSpy = vi
|
|
235
|
+
.spyOn(window, 'open')
|
|
236
|
+
.mockImplementation(() => null)
|
|
237
|
+
renderWithProviders(
|
|
238
|
+
<ChannelActionsMenu
|
|
239
|
+
{...defaultProps()}
|
|
240
|
+
onReportParticipantClick={onReportParticipantClick}
|
|
241
|
+
/>
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
await openMenu()
|
|
245
|
+
await userEvent.click(screen.getByText('Report'))
|
|
246
|
+
|
|
247
|
+
await waitFor(() => {
|
|
248
|
+
expect(onReportParticipantClick).toHaveBeenCalledOnce()
|
|
249
|
+
expect(windowOpenSpy).toHaveBeenCalled()
|
|
250
|
+
})
|
|
251
|
+
windowOpenSpy.mockRestore()
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it('hides actions based on the show* flags', async () => {
|
|
255
|
+
renderWithProviders(
|
|
256
|
+
<ChannelActionsMenu
|
|
257
|
+
{...defaultProps()}
|
|
258
|
+
showDeleteConversation={false}
|
|
259
|
+
showBlockParticipant={false}
|
|
260
|
+
showReportParticipant={false}
|
|
261
|
+
customChannelActions={<li data-testid="custom-action">Custom</li>}
|
|
262
|
+
/>
|
|
263
|
+
)
|
|
264
|
+
|
|
265
|
+
await openMenu()
|
|
266
|
+
|
|
267
|
+
expect(screen.queryByText('Delete Conversation')).not.toBeInTheDocument()
|
|
268
|
+
expect(screen.queryByText('Block')).not.toBeInTheDocument()
|
|
269
|
+
expect(screen.queryByText('Report')).not.toBeInTheDocument()
|
|
270
|
+
expect(screen.getByTestId('custom-action')).toBeInTheDocument()
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('does not fetch blocked users until the menu is opened', async () => {
|
|
274
|
+
renderWithProviders(<ChannelActionsMenu {...defaultProps()} />)
|
|
275
|
+
|
|
276
|
+
expect(getBlockedUsersMock).not.toHaveBeenCalled()
|
|
277
|
+
|
|
278
|
+
await openMenu()
|
|
279
|
+
|
|
280
|
+
await waitFor(() => {
|
|
281
|
+
expect(getBlockedUsersMock).toHaveBeenCalled()
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
it('renders nothing when there is no participant', () => {
|
|
286
|
+
const { container } = renderWithProviders(
|
|
287
|
+
<ChannelActionsMenu {...defaultProps()} participant={undefined} />
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
expect(container).toBeEmptyDOMElement()
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('renders nothing when no actions are available', () => {
|
|
294
|
+
const { container } = renderWithProviders(
|
|
295
|
+
<ChannelActionsMenu
|
|
296
|
+
{...defaultProps()}
|
|
297
|
+
showDeleteConversation={false}
|
|
298
|
+
showBlockParticipant={false}
|
|
299
|
+
showReportParticipant={false}
|
|
300
|
+
/>
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
expect(container).toBeEmptyDOMElement()
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
DotsThreeIcon,
|
|
3
|
+
FlagIcon,
|
|
4
|
+
ProhibitInsetIcon,
|
|
5
|
+
SignOutIcon,
|
|
6
|
+
SpinnerGapIcon,
|
|
7
|
+
} from '@phosphor-icons/react'
|
|
8
|
+
import classNames from 'classnames'
|
|
9
|
+
import React, { useCallback, useEffect, useId, useRef, useState } from 'react'
|
|
10
|
+
import type { Channel as ChannelType, ChannelMemberResponse } from 'stream-chat'
|
|
11
|
+
|
|
12
|
+
import { useChannelModerationActions } from '../../hooks/useChannelModerationActions'
|
|
13
|
+
import ActionButton from '../ActionButton'
|
|
14
|
+
|
|
15
|
+
export interface ChannelActionsMenuProps {
|
|
16
|
+
channel: ChannelType
|
|
17
|
+
participant: ChannelMemberResponse | undefined
|
|
18
|
+
showDeleteConversation?: boolean
|
|
19
|
+
/**
|
|
20
|
+
* Show the Block/Unblock action. Defaults to true.
|
|
21
|
+
* Set false to hide it (e.g. the Linktree official channel).
|
|
22
|
+
*/
|
|
23
|
+
showBlockParticipant?: boolean
|
|
24
|
+
/**
|
|
25
|
+
* Show the Report action. Defaults to true.
|
|
26
|
+
* Set false to hide it (e.g. the Linktree official channel).
|
|
27
|
+
*/
|
|
28
|
+
showReportParticipant?: boolean
|
|
29
|
+
onLeaveConversation?: (channel: ChannelType) => void
|
|
30
|
+
onBlockParticipant?: (participantId?: string) => void
|
|
31
|
+
onDeleteConversationClick?: () => void
|
|
32
|
+
onBlockParticipantClick?: () => void
|
|
33
|
+
onReportParticipantClick?: () => void
|
|
34
|
+
customChannelActions?: React.ReactNode
|
|
35
|
+
/** Classes applied to the trigger ("...") button so it matches the header. */
|
|
36
|
+
triggerClassName?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Conversation overflow ("...") menu rendered as an anchored popover.
|
|
41
|
+
*
|
|
42
|
+
* Replaces the slide-in sidebar dialog for moderation actions: the trigger
|
|
43
|
+
* opens a small popover that lists the available options (delete, block,
|
|
44
|
+
* report) inline with their icons.
|
|
45
|
+
*/
|
|
46
|
+
export const ChannelActionsMenu: React.FC<ChannelActionsMenuProps> = ({
|
|
47
|
+
channel,
|
|
48
|
+
participant,
|
|
49
|
+
showDeleteConversation = true,
|
|
50
|
+
showBlockParticipant = true,
|
|
51
|
+
showReportParticipant = true,
|
|
52
|
+
onLeaveConversation,
|
|
53
|
+
onBlockParticipant,
|
|
54
|
+
onDeleteConversationClick,
|
|
55
|
+
onBlockParticipantClick,
|
|
56
|
+
onReportParticipantClick,
|
|
57
|
+
customChannelActions,
|
|
58
|
+
triggerClassName,
|
|
59
|
+
}) => {
|
|
60
|
+
const [isOpen, setIsOpen] = useState(false)
|
|
61
|
+
const containerRef = useRef<HTMLDivElement>(null)
|
|
62
|
+
const menuId = useId()
|
|
63
|
+
|
|
64
|
+
const close = useCallback(() => setIsOpen(false), [])
|
|
65
|
+
|
|
66
|
+
const {
|
|
67
|
+
isParticipantBlocked,
|
|
68
|
+
isCheckingBlockedStatus,
|
|
69
|
+
isLeaving,
|
|
70
|
+
isUpdatingBlockStatus,
|
|
71
|
+
handleLeaveConversation,
|
|
72
|
+
handleBlockUser,
|
|
73
|
+
handleUnblockUser,
|
|
74
|
+
handleReportUser,
|
|
75
|
+
} = useChannelModerationActions({
|
|
76
|
+
channel,
|
|
77
|
+
participant,
|
|
78
|
+
showBlockParticipant,
|
|
79
|
+
enabled: isOpen,
|
|
80
|
+
onLeaveConversation,
|
|
81
|
+
onBlockParticipant,
|
|
82
|
+
onDeleteConversationClick,
|
|
83
|
+
onBlockParticipantClick,
|
|
84
|
+
onReportParticipantClick,
|
|
85
|
+
onActionComplete: close,
|
|
86
|
+
logLabel: 'ChannelActionsMenu',
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
// Close the popover on outside click or Escape.
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!isOpen) return
|
|
92
|
+
|
|
93
|
+
const handlePointerDown = (event: MouseEvent) => {
|
|
94
|
+
if (
|
|
95
|
+
containerRef.current &&
|
|
96
|
+
!containerRef.current.contains(event.target as Node)
|
|
97
|
+
) {
|
|
98
|
+
setIsOpen(false)
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const handleKeyDown = (event: KeyboardEvent) => {
|
|
103
|
+
if (event.key === 'Escape') {
|
|
104
|
+
setIsOpen(false)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
document.addEventListener('mousedown', handlePointerDown)
|
|
109
|
+
document.addEventListener('keydown', handleKeyDown)
|
|
110
|
+
|
|
111
|
+
return () => {
|
|
112
|
+
document.removeEventListener('mousedown', handlePointerDown)
|
|
113
|
+
document.removeEventListener('keydown', handleKeyDown)
|
|
114
|
+
}
|
|
115
|
+
}, [isOpen])
|
|
116
|
+
|
|
117
|
+
if (!participant) return null
|
|
118
|
+
|
|
119
|
+
const hasActions =
|
|
120
|
+
showDeleteConversation ||
|
|
121
|
+
showBlockParticipant ||
|
|
122
|
+
showReportParticipant ||
|
|
123
|
+
Boolean(customChannelActions)
|
|
124
|
+
|
|
125
|
+
if (!hasActions) return null
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div ref={containerRef} className="relative">
|
|
129
|
+
<button
|
|
130
|
+
className={triggerClassName}
|
|
131
|
+
type="button"
|
|
132
|
+
aria-haspopup="true"
|
|
133
|
+
aria-expanded={isOpen}
|
|
134
|
+
aria-controls={isOpen ? menuId : undefined}
|
|
135
|
+
onClick={() => setIsOpen((open) => !open)}
|
|
136
|
+
>
|
|
137
|
+
<DotsThreeIcon className="size-5 text-black/90" />
|
|
138
|
+
<span className="sr-only">More options</span>
|
|
139
|
+
</button>
|
|
140
|
+
|
|
141
|
+
{isOpen && (
|
|
142
|
+
<div
|
|
143
|
+
id={menuId}
|
|
144
|
+
aria-label="Conversation options"
|
|
145
|
+
className={classNames(
|
|
146
|
+
'absolute right-0 top-full z-50 mt-2 w-56 overflow-hidden',
|
|
147
|
+
'rounded-lg border border-sand bg-white p-1 shadow-max-elevation-light'
|
|
148
|
+
)}
|
|
149
|
+
>
|
|
150
|
+
<ul className="flex flex-col gap-1">
|
|
151
|
+
{showDeleteConversation && (
|
|
152
|
+
<li>
|
|
153
|
+
<ActionButton
|
|
154
|
+
onClick={handleLeaveConversation}
|
|
155
|
+
disabled={isLeaving}
|
|
156
|
+
aria-busy={isLeaving}
|
|
157
|
+
>
|
|
158
|
+
{isLeaving ? (
|
|
159
|
+
<SpinnerGapIcon className="h-5 w-5 animate-spin" />
|
|
160
|
+
) : (
|
|
161
|
+
<SignOutIcon className="h-5 w-5" />
|
|
162
|
+
)}
|
|
163
|
+
<span>Delete Conversation</span>
|
|
164
|
+
</ActionButton>
|
|
165
|
+
</li>
|
|
166
|
+
)}
|
|
167
|
+
{showBlockParticipant && (
|
|
168
|
+
<li>
|
|
169
|
+
{isCheckingBlockedStatus ? (
|
|
170
|
+
// Block status not yet resolved — show a disabled placeholder
|
|
171
|
+
// so a premature click can't act on a stale block state.
|
|
172
|
+
<ActionButton disabled aria-busy>
|
|
173
|
+
<SpinnerGapIcon className="h-5 w-5 animate-spin" />
|
|
174
|
+
<span>Block</span>
|
|
175
|
+
</ActionButton>
|
|
176
|
+
) : isParticipantBlocked ? (
|
|
177
|
+
<ActionButton
|
|
178
|
+
onClick={handleUnblockUser}
|
|
179
|
+
disabled={isUpdatingBlockStatus}
|
|
180
|
+
aria-busy={isUpdatingBlockStatus}
|
|
181
|
+
>
|
|
182
|
+
{isUpdatingBlockStatus ? (
|
|
183
|
+
<SpinnerGapIcon className="h-5 w-5 animate-spin" />
|
|
184
|
+
) : (
|
|
185
|
+
<ProhibitInsetIcon className="h-5 w-5" />
|
|
186
|
+
)}
|
|
187
|
+
<span>Unblock</span>
|
|
188
|
+
</ActionButton>
|
|
189
|
+
) : (
|
|
190
|
+
<ActionButton
|
|
191
|
+
onClick={handleBlockUser}
|
|
192
|
+
disabled={isUpdatingBlockStatus}
|
|
193
|
+
aria-busy={isUpdatingBlockStatus}
|
|
194
|
+
>
|
|
195
|
+
{isUpdatingBlockStatus ? (
|
|
196
|
+
<SpinnerGapIcon className="h-5 w-5 animate-spin" />
|
|
197
|
+
) : (
|
|
198
|
+
<ProhibitInsetIcon className="h-5 w-5" />
|
|
199
|
+
)}
|
|
200
|
+
<span>Block</span>
|
|
201
|
+
</ActionButton>
|
|
202
|
+
)}
|
|
203
|
+
</li>
|
|
204
|
+
)}
|
|
205
|
+
{showReportParticipant && (
|
|
206
|
+
<li>
|
|
207
|
+
<ActionButton variant="danger" onClick={handleReportUser}>
|
|
208
|
+
<FlagIcon className="h-5 w-5" />
|
|
209
|
+
<span>Report</span>
|
|
210
|
+
</ActionButton>
|
|
211
|
+
</li>
|
|
212
|
+
)}
|
|
213
|
+
{customChannelActions}
|
|
214
|
+
</ul>
|
|
215
|
+
</div>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default ChannelActionsMenu
|