@ermis-network/ermis-chat-react 1.0.5 → 1.0.7
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.cjs +2411 -1309
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +471 -16
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +145 -1
- package/dist/index.d.ts +145 -1
- package/dist/index.mjs +2340 -1242
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +231 -0
- package/src/components/ChannelHeader.tsx +38 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
- package/src/components/ChannelList.tsx +467 -45
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/MessageInput.tsx +19 -2
- package/src/components/MessageItem.tsx +8 -11
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +7 -9
- package/src/components/PendingOverlay.tsx +41 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/VirtualMessageList.tsx +74 -43
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useChannelCapabilities.ts +7 -3
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +24 -3
- package/src/hooks/useChannelRowUpdates.ts +6 -0
- package/src/hooks/useMessageActions.ts +1 -1
- package/src/index.ts +6 -1
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +217 -6
- package/src/styles/_message-bubble.css +75 -9
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +51 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +157 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ermis-network/ermis-chat-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.7",
|
|
4
4
|
"description": "React UI components for Ermis Chat",
|
|
5
5
|
"author": "Ermis",
|
|
6
6
|
"homepage": "https://ermis.network/",
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
"/src"
|
|
21
21
|
],
|
|
22
22
|
"dependencies": {
|
|
23
|
-
"@ermis-network/ermis-chat-sdk": "1.0.
|
|
23
|
+
"@ermis-network/ermis-chat-sdk": "1.0.7",
|
|
24
24
|
"virtua": "^0.48.8"
|
|
25
25
|
},
|
|
26
26
|
"peerDependencies": {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export type BannedOverlayProps = {
|
|
4
|
+
isBlocked?: boolean;
|
|
5
|
+
blockedTitle: string;
|
|
6
|
+
bannedTitle: string;
|
|
7
|
+
blockedSubtitle: string;
|
|
8
|
+
bannedSubtitle: string;
|
|
9
|
+
onUnblock?: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const BannedOverlay: React.FC<BannedOverlayProps> = React.memo(({
|
|
13
|
+
isBlocked,
|
|
14
|
+
blockedTitle,
|
|
15
|
+
bannedTitle,
|
|
16
|
+
blockedSubtitle,
|
|
17
|
+
bannedSubtitle,
|
|
18
|
+
onUnblock,
|
|
19
|
+
}) => (
|
|
20
|
+
<div className="ermis-message-list__banned-overlay">
|
|
21
|
+
<div className="ermis-message-list__banned-overlay-icon">
|
|
22
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
23
|
+
<circle cx="12" cy="12" r="10" />
|
|
24
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
25
|
+
</svg>
|
|
26
|
+
</div>
|
|
27
|
+
<span className="ermis-message-list__banned-overlay-title">{isBlocked ? blockedTitle : bannedTitle}</span>
|
|
28
|
+
<span className="ermis-message-list__banned-overlay-subtitle">{isBlocked ? blockedSubtitle : bannedSubtitle}</span>
|
|
29
|
+
{isBlocked && onUnblock && (
|
|
30
|
+
<button
|
|
31
|
+
className="ermis-message-list__unblock-btn"
|
|
32
|
+
onClick={onUnblock}
|
|
33
|
+
>
|
|
34
|
+
Unblock
|
|
35
|
+
</button>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
));
|
|
39
|
+
|
|
40
|
+
BannedOverlay.displayName = 'BannedOverlay';
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { ChannelAction, ChannelActionLabels, ChannelActionIcons, ChannelActionsProps } from '../types';
|
|
4
|
+
import { Dropdown } from './Dropdown';
|
|
5
|
+
|
|
6
|
+
/* ----------------------------------------------------------
|
|
7
|
+
SVG Icons for default actions
|
|
8
|
+
---------------------------------------------------------- */
|
|
9
|
+
const PinIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /></svg>);
|
|
10
|
+
const UnpinIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /><line x1="3" y1="3" x2="21" y2="21" /></svg>);
|
|
11
|
+
const BlockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" /></svg>);
|
|
12
|
+
const LeaveIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>);
|
|
13
|
+
const TrashIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>);
|
|
14
|
+
const LockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>);
|
|
15
|
+
const UnlockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" /></svg>);
|
|
16
|
+
const CreateTopicIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>);
|
|
17
|
+
const EditIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>);
|
|
18
|
+
const MoreIcon = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg>);
|
|
19
|
+
|
|
20
|
+
/* ----------------------------------------------------------
|
|
21
|
+
computeDefaultActions
|
|
22
|
+
Derives a list of ChannelAction[] based on channel type
|
|
23
|
+
and the current user's role. Currently actions only log
|
|
24
|
+
to console — real API calls will be wired later.
|
|
25
|
+
---------------------------------------------------------- */
|
|
26
|
+
export function computeDefaultActions(
|
|
27
|
+
channel: Channel,
|
|
28
|
+
currentUserId?: string,
|
|
29
|
+
options?: {
|
|
30
|
+
onAddTopic?: (channel: Channel) => void;
|
|
31
|
+
onEditTopic?: (channel: Channel) => void;
|
|
32
|
+
onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
|
|
33
|
+
isBlocked?: boolean;
|
|
34
|
+
actionLabels?: ChannelActionLabels;
|
|
35
|
+
actionIcons?: ChannelActionIcons;
|
|
36
|
+
},
|
|
37
|
+
): ChannelAction[] {
|
|
38
|
+
const actions: ChannelAction[] = [];
|
|
39
|
+
if (!currentUserId) return actions;
|
|
40
|
+
|
|
41
|
+
const isDirect = channel.type === 'messaging';
|
|
42
|
+
const isTeamOrMeeting = channel.type === 'team' || channel.type === 'meeting';
|
|
43
|
+
const isTopic = channel.type === 'topic' || Boolean(channel.data?.parent_cid);
|
|
44
|
+
const isClosed = channel.data?.is_closed_topic === true;
|
|
45
|
+
|
|
46
|
+
const ms = channel.state?.members?.[currentUserId] || channel.state?.membership;
|
|
47
|
+
const role = ms?.channel_role || (ms as any)?.role;
|
|
48
|
+
const isBlocked = options?.isBlocked !== undefined ? options.isBlocked : (ms as any)?.blocked;
|
|
49
|
+
const isPinned = channel.data?.is_pinned === true;
|
|
50
|
+
|
|
51
|
+
// Pin / Unpin — available for all channel types
|
|
52
|
+
const actionLabels = options?.actionLabels;
|
|
53
|
+
|
|
54
|
+
const pinLabel = isPinned
|
|
55
|
+
? (isTopic ? (actionLabels?.unpinTopic || 'Unpin topic') : (actionLabels?.unpinChannel || 'Unpin channel'))
|
|
56
|
+
: (isTopic ? (actionLabels?.pinTopic || 'Pin topic') : (actionLabels?.pinChannel || 'Pin channel'));
|
|
57
|
+
|
|
58
|
+
const actionIcons = options?.actionIcons;
|
|
59
|
+
|
|
60
|
+
const pinIcon = isPinned
|
|
61
|
+
? (actionIcons?.UnpinIcon || <UnpinIcon />)
|
|
62
|
+
: (actionIcons?.PinIcon || <PinIcon />);
|
|
63
|
+
|
|
64
|
+
actions.push({
|
|
65
|
+
id: isPinned ? 'unpin' : 'pin',
|
|
66
|
+
label: pinLabel,
|
|
67
|
+
icon: pinIcon,
|
|
68
|
+
onClick: async (ch) => {
|
|
69
|
+
try {
|
|
70
|
+
if (isPinned) {
|
|
71
|
+
await ch.unpin();
|
|
72
|
+
} else {
|
|
73
|
+
await ch.pin();
|
|
74
|
+
}
|
|
75
|
+
} catch (e) {
|
|
76
|
+
console.error('Error toggling pin state', e);
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (isDirect) {
|
|
82
|
+
// Direct channel: Block / Unblock
|
|
83
|
+
actions.push({
|
|
84
|
+
id: isBlocked ? 'unblock' : 'block',
|
|
85
|
+
label: isBlocked ? (actionLabels?.unblockUser || 'Unblock user') : (actionLabels?.blockUser || 'Block user'),
|
|
86
|
+
icon: isBlocked ? (actionIcons?.UnblockIcon || <BlockIcon />) : (actionIcons?.BlockIcon || <BlockIcon />),
|
|
87
|
+
isDanger: !isBlocked,
|
|
88
|
+
onClick: async (ch) => {
|
|
89
|
+
try {
|
|
90
|
+
if (isBlocked) {
|
|
91
|
+
await ch.unblockUser();
|
|
92
|
+
} else {
|
|
93
|
+
await ch.blockUser();
|
|
94
|
+
}
|
|
95
|
+
} catch (e) {
|
|
96
|
+
console.error('Error toggling block state', e);
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
} else if (isTopic) {
|
|
101
|
+
// Topic: Edit topic (owner & moder only)
|
|
102
|
+
if (role === 'owner' || role === 'moder') {
|
|
103
|
+
actions.push({
|
|
104
|
+
id: 'edit_topic',
|
|
105
|
+
label: actionLabels?.editTopic || 'Edit topic',
|
|
106
|
+
icon: actionIcons?.EditTopicIcon || <EditIcon />,
|
|
107
|
+
onClick: (ch) => {
|
|
108
|
+
options?.onEditTopic?.(ch);
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
// Topic: Close / Reopen (owner & moder only)
|
|
113
|
+
if (role === 'owner' || role === 'moder') {
|
|
114
|
+
actions.push({
|
|
115
|
+
id: isClosed ? 'reopen' : 'close',
|
|
116
|
+
label: isClosed ? (actionLabels?.reopenTopic || 'Reopen topic') : (actionLabels?.closeTopic || 'Close topic'),
|
|
117
|
+
icon: isClosed ? (actionIcons?.ReopenTopicIcon || <UnlockIcon />) : (actionIcons?.CloseTopicIcon || <LockIcon />),
|
|
118
|
+
isDanger: !isClosed,
|
|
119
|
+
onClick: (ch) => {
|
|
120
|
+
options?.onToggleCloseTopic?.(ch, isClosed);
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
} else if (isTeamOrMeeting) {
|
|
125
|
+
// Team channel: Create Topic (owner & moder, only if topics enabled)
|
|
126
|
+
const hasTopicsEnabled = Boolean(channel.data?.topics_enabled);
|
|
127
|
+
if (hasTopicsEnabled && (role === 'owner' || role === 'moder') && options?.onAddTopic) {
|
|
128
|
+
actions.push({
|
|
129
|
+
id: 'create_topic',
|
|
130
|
+
label: actionLabels?.createTopic || 'Create topic',
|
|
131
|
+
icon: actionIcons?.CreateTopicIcon || <CreateTopicIcon />,
|
|
132
|
+
onClick: (ch) => { options.onAddTopic!(ch); },
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
if (role === 'owner') {
|
|
136
|
+
actions.push({
|
|
137
|
+
id: 'delete',
|
|
138
|
+
label: actionLabels?.deleteChannel || 'Delete channel',
|
|
139
|
+
icon: actionIcons?.DeleteChannelIcon || <TrashIcon />,
|
|
140
|
+
isDanger: true,
|
|
141
|
+
onClick: async (ch) => {
|
|
142
|
+
try {
|
|
143
|
+
await ch.delete();
|
|
144
|
+
} catch (e) {
|
|
145
|
+
console.error('Error deleting channel', e);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
if (role === 'moder' || role === 'member') {
|
|
151
|
+
actions.push({
|
|
152
|
+
id: 'leave',
|
|
153
|
+
label: actionLabels?.leaveChannel || 'Leave channel',
|
|
154
|
+
icon: actionIcons?.LeaveChannelIcon || <LeaveIcon />,
|
|
155
|
+
isDanger: true,
|
|
156
|
+
onClick: async (ch) => {
|
|
157
|
+
try {
|
|
158
|
+
await ch.removeMembers([currentUserId]);
|
|
159
|
+
} catch (e) {
|
|
160
|
+
console.error('Error leaving channel', e);
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return actions;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/* ----------------------------------------------------------
|
|
171
|
+
DefaultChannelActions
|
|
172
|
+
The default UI component that renders the "more" trigger
|
|
173
|
+
button and the dropdown menu. Consumer can fully replace
|
|
174
|
+
this via ChannelActionsComponent prop.
|
|
175
|
+
---------------------------------------------------------- */
|
|
176
|
+
export const DefaultChannelActions: React.FC<ChannelActionsProps> = React.memo(({ channel, actions, onClose }) => {
|
|
177
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
178
|
+
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
179
|
+
|
|
180
|
+
const handleActionsClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
181
|
+
e.stopPropagation();
|
|
182
|
+
setAnchorRect(e.currentTarget.getBoundingClientRect());
|
|
183
|
+
setDropdownOpen(true);
|
|
184
|
+
}, []);
|
|
185
|
+
|
|
186
|
+
const handleClose = useCallback(() => {
|
|
187
|
+
setDropdownOpen(false);
|
|
188
|
+
setAnchorRect(null);
|
|
189
|
+
onClose();
|
|
190
|
+
}, [onClose]);
|
|
191
|
+
|
|
192
|
+
if (!actions || actions.length === 0) return null;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<>
|
|
196
|
+
<button
|
|
197
|
+
type="button"
|
|
198
|
+
className={`ermis-channel-list__actions-trigger ${dropdownOpen ? 'ermis-channel-list__actions-trigger--active' : ''}`}
|
|
199
|
+
onClick={handleActionsClick}
|
|
200
|
+
title="More actions"
|
|
201
|
+
>
|
|
202
|
+
<MoreIcon />
|
|
203
|
+
</button>
|
|
204
|
+
<Dropdown
|
|
205
|
+
isOpen={dropdownOpen}
|
|
206
|
+
anchorRect={anchorRect}
|
|
207
|
+
onClose={handleClose}
|
|
208
|
+
align="right"
|
|
209
|
+
>
|
|
210
|
+
<div className="ermis-dropdown__menu">
|
|
211
|
+
{actions.map((action) => (
|
|
212
|
+
<button
|
|
213
|
+
key={action.id}
|
|
214
|
+
className={`ermis-dropdown__item ${action.isDanger ? 'ermis-dropdown__item--danger' : ''}`}
|
|
215
|
+
onClick={(e) => {
|
|
216
|
+
e.stopPropagation();
|
|
217
|
+
handleClose();
|
|
218
|
+
action.onClick(channel, e);
|
|
219
|
+
}}
|
|
220
|
+
>
|
|
221
|
+
{action.icon}
|
|
222
|
+
<span>{action.label}</span>
|
|
223
|
+
</button>
|
|
224
|
+
))}
|
|
225
|
+
</div>
|
|
226
|
+
</Dropdown>
|
|
227
|
+
</>
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
DefaultChannelActions.displayName = 'DefaultChannelActions';
|
|
@@ -60,17 +60,53 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
|
|
|
60
60
|
[image, activeChannel?.data?.image, channelUpdateCount],
|
|
61
61
|
);
|
|
62
62
|
|
|
63
|
+
const teamName = useMemo(() => {
|
|
64
|
+
if (!activeChannel) return undefined;
|
|
65
|
+
|
|
66
|
+
// If it's a topic, derive from parent_cid
|
|
67
|
+
const parentCid = activeChannel.data?.parent_cid as string | undefined;
|
|
68
|
+
if (parentCid && client.activeChannels[parentCid]) {
|
|
69
|
+
return client.activeChannels[parentCid].data?.name || client.activeChannels[parentCid].cid;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// If it's a topics-enabled team channel (the general proxy), the proxy overrides data.name.
|
|
73
|
+
// We can pull the original name from the SDK cache.
|
|
74
|
+
if ((activeChannel.type === 'team' || activeChannel.type === 'meeting') && activeChannel.data?.topics_enabled) {
|
|
75
|
+
const rawChannel = client.activeChannels[activeChannel.cid];
|
|
76
|
+
if (rawChannel && rawChannel.data?.name && rawChannel.data.name !== activeChannel.data?.name) {
|
|
77
|
+
return rawChannel.data.name;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return undefined;
|
|
82
|
+
}, [activeChannel, client.activeChannels]);
|
|
83
|
+
|
|
63
84
|
if (!activeChannel) return null;
|
|
64
85
|
|
|
65
86
|
return (
|
|
66
87
|
<div className={`ermis-channel-header${className ? ` ${className}` : ''}`}>
|
|
67
|
-
|
|
88
|
+
{activeChannel.data?.parent_cid ? (
|
|
89
|
+
<div className="ermis-channel-header__topic-avatar">
|
|
90
|
+
{channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
|
|
91
|
+
? channelImage.replace('emoji://', '')
|
|
92
|
+
: '#'}
|
|
93
|
+
</div>
|
|
94
|
+
) : (
|
|
95
|
+
<AvatarComponent image={channelImage} name={teamName || channelName} size={32} />
|
|
96
|
+
)}
|
|
68
97
|
|
|
69
98
|
<div className="ermis-channel-header__info">
|
|
70
99
|
{renderTitle ? (
|
|
71
100
|
renderTitle(activeChannel)
|
|
72
101
|
) : (
|
|
73
|
-
<div className="ermis-channel-
|
|
102
|
+
<div className="ermis-channel-header__title-container">
|
|
103
|
+
{teamName && (
|
|
104
|
+
<div className="ermis-channel-header__team-name">
|
|
105
|
+
{teamName}
|
|
106
|
+
</div>
|
|
107
|
+
)}
|
|
108
|
+
<div className="ermis-channel-header__name">{channelName}</div>
|
|
109
|
+
</div>
|
|
74
110
|
)}
|
|
75
111
|
{subtitle && (
|
|
76
112
|
<div className="ermis-channel-header__subtitle">{subtitle}</div>
|
|
@@ -6,6 +6,7 @@ import { Avatar } from '../Avatar';
|
|
|
6
6
|
import { DefaultChannelInfoTabs } from './ChannelInfoTabs';
|
|
7
7
|
import { AddMemberModal } from './AddMemberModal';
|
|
8
8
|
import { EditChannelModal } from './EditChannelModal';
|
|
9
|
+
import { TopicModal } from '../TopicModal';
|
|
9
10
|
import { MessageSearchPanel } from './MessageSearchPanel';
|
|
10
11
|
import { ChannelSettingsPanel } from './ChannelSettingsPanel';
|
|
11
12
|
import type {
|
|
@@ -32,10 +33,22 @@ export const DefaultChannelInfoHeader: React.FC<ChannelInfoHeaderProps> = React.
|
|
|
32
33
|
});
|
|
33
34
|
DefaultChannelInfoHeader.displayName = 'DefaultChannelInfoHeader';
|
|
34
35
|
|
|
35
|
-
export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.memo(({ channelName, channelImage, channelDescription, AvatarComponent, canEdit, onEditClick, isPublic, isTeamChannel }) => {
|
|
36
|
+
export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.memo(({ channelName, channelImage, channelDescription, AvatarComponent, canEdit, onEditClick, isPublic, isTeamChannel, parentChannelName, isTopic }) => {
|
|
37
|
+
const renderAvatar = () => {
|
|
38
|
+
if (isTopic && channelImage && channelImage.startsWith('emoji://')) {
|
|
39
|
+
const emoji = channelImage.replace('emoji://', '');
|
|
40
|
+
return (
|
|
41
|
+
<div className="ermis-channel-info__topic-emoji-avatar">
|
|
42
|
+
{emoji}
|
|
43
|
+
</div>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
return <AvatarComponent image={channelImage} name={channelName} size={80} className="ermis-channel-info__avatar" />;
|
|
47
|
+
};
|
|
48
|
+
|
|
36
49
|
return (
|
|
37
50
|
<div className="ermis-channel-info__cover">
|
|
38
|
-
|
|
51
|
+
{renderAvatar()}
|
|
39
52
|
<div className="ermis-channel-info__name-row">
|
|
40
53
|
<h2 className="ermis-channel-info__name">{channelName}</h2>
|
|
41
54
|
{canEdit && onEditClick && (
|
|
@@ -47,6 +60,11 @@ export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.me
|
|
|
47
60
|
</button>
|
|
48
61
|
)}
|
|
49
62
|
</div>
|
|
63
|
+
{parentChannelName && (
|
|
64
|
+
<div className="ermis-channel-info__parent-name">
|
|
65
|
+
{parentChannelName}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
50
68
|
{isTeamChannel && (
|
|
51
69
|
<span className={`ermis-channel-info__type-badge ${isPublic ? 'ermis-channel-info__type-badge--public' : 'ermis-channel-info__type-badge--private'}`}>
|
|
52
70
|
{isPublic ? (
|
|
@@ -74,10 +92,10 @@ DefaultChannelInfoCover.displayName = 'DefaultChannelInfoCover';
|
|
|
74
92
|
|
|
75
93
|
export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = React.memo(({
|
|
76
94
|
onSearchClick, onSettingsClick, onLeaveChannel, onDeleteChannel,
|
|
77
|
-
onBlockUser, onUnblockUser,
|
|
78
|
-
isTeamChannel, isBlocked, currentUserRole,
|
|
95
|
+
onBlockUser, onUnblockUser, onCloseTopic, onReopenTopic,
|
|
96
|
+
isTeamChannel, isTopic, isClosedTopic, isBlocked, currentUserRole,
|
|
79
97
|
searchLabel = 'Search', settingsLabel = 'Settings', deleteLabel = 'Delete', leaveLabel = 'Leave',
|
|
80
|
-
blockLabel = 'Block', unblockLabel = 'Unblock'
|
|
98
|
+
blockLabel = 'Block', unblockLabel = 'Unblock', closeTopicLabel = 'Close Topic', reopenTopicLabel = 'Reopen Topic'
|
|
81
99
|
}) => {
|
|
82
100
|
return (
|
|
83
101
|
<div className="ermis-channel-info__actions">
|
|
@@ -125,8 +143,32 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
|
|
|
125
143
|
</button>
|
|
126
144
|
)
|
|
127
145
|
)}
|
|
146
|
+
{/* Topics: Close/Reopen Topic for owner/moder */}
|
|
147
|
+
{isTopic && (currentUserRole === 'owner' || currentUserRole === 'moder') && (
|
|
148
|
+
isClosedTopic ? (
|
|
149
|
+
<button className="ermis-channel-info__action-btn" onClick={onReopenTopic}>
|
|
150
|
+
<div className="ermis-channel-info__action-icon">
|
|
151
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
152
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
153
|
+
<path d="M7 11V7a5 5 0 0 1 9.9-1" />
|
|
154
|
+
</svg>
|
|
155
|
+
</div>
|
|
156
|
+
<span>{reopenTopicLabel}</span>
|
|
157
|
+
</button>
|
|
158
|
+
) : (
|
|
159
|
+
<button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onCloseTopic}>
|
|
160
|
+
<div className="ermis-channel-info__action-icon">
|
|
161
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
162
|
+
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
163
|
+
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
164
|
+
</svg>
|
|
165
|
+
</div>
|
|
166
|
+
<span>{closeTopicLabel}</span>
|
|
167
|
+
</button>
|
|
168
|
+
)
|
|
169
|
+
)}
|
|
128
170
|
{/* Block/Unblock — messaging (1-1) channels only */}
|
|
129
|
-
{!isTeamChannel && (
|
|
171
|
+
{!isTeamChannel && !isTopic && (
|
|
130
172
|
isBlocked ? (
|
|
131
173
|
<button className="ermis-channel-info__action-btn" onClick={onUnblockUser}>
|
|
132
174
|
<div className="ermis-channel-info__action-icon">
|
|
@@ -160,7 +202,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
160
202
|
className = '',
|
|
161
203
|
AvatarComponent = Avatar,
|
|
162
204
|
onClose,
|
|
163
|
-
title
|
|
205
|
+
title: titleProp,
|
|
164
206
|
HeaderComponent = DefaultChannelInfoHeader,
|
|
165
207
|
CoverComponent = DefaultChannelInfoCover,
|
|
166
208
|
ActionsComponent = DefaultChannelInfoActions,
|
|
@@ -216,6 +258,12 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
216
258
|
onUnblockUser: onUnblockUserProp,
|
|
217
259
|
actionsBlockLabel,
|
|
218
260
|
actionsUnblockLabel,
|
|
261
|
+
actionsCloseTopicLabel,
|
|
262
|
+
actionsReopenTopicLabel,
|
|
263
|
+
// Settings panel customizations
|
|
264
|
+
settingsWorkspaceTopicsTitle,
|
|
265
|
+
settingsTopicsFeatureName,
|
|
266
|
+
settingsTopicsFeatureDescription,
|
|
219
267
|
} = props;
|
|
220
268
|
|
|
221
269
|
const { activeChannel, client } = useChatClient();
|
|
@@ -225,7 +273,22 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
225
273
|
|
|
226
274
|
const currentUserId = client?.userID;
|
|
227
275
|
const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
|
|
228
|
-
const isTeamChannel = channel?.type === 'team';
|
|
276
|
+
const isTeamChannel = channel?.type === 'team' || channel?.type === 'meeting';
|
|
277
|
+
const isTopic = Boolean(channel?.data?.parent_cid) || channel?.type === 'topic';
|
|
278
|
+
const isClosedTopic = channel?.data?.is_closed_topic === true;
|
|
279
|
+
const title = titleProp !== undefined ? titleProp : (isTopic ? 'Topic Info' : 'Channel Info');
|
|
280
|
+
|
|
281
|
+
const parentCid = channel?.data?.parent_cid as string | undefined;
|
|
282
|
+
const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
|
|
283
|
+
let parentChannelName = parentChannel?.data?.name || (parentCid ? 'Unknown' : undefined);
|
|
284
|
+
|
|
285
|
+
// If this is the proxy 'general' channel, its real name is the parent team name
|
|
286
|
+
if ((channel?.type === 'team' || channel?.type === 'meeting') && channel?.data?.name === 'general' && channel.cid) {
|
|
287
|
+
const realChannelName = client?.activeChannels[channel.cid]?.data?.name;
|
|
288
|
+
if (realChannelName && realChannelName !== 'general') {
|
|
289
|
+
parentChannelName = realChannelName;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
229
292
|
|
|
230
293
|
const handleDeleteChannel = useCallback(async () => {
|
|
231
294
|
if (onDeleteChannelProp) return onDeleteChannelProp();
|
|
@@ -293,20 +356,35 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
293
356
|
try { await channel.unblockUser(); } catch (e) { console.error('Error unblocking user', e); }
|
|
294
357
|
}, [channel, onUnblockUserProp]);
|
|
295
358
|
|
|
359
|
+
const handleCloseTopic = useCallback(async () => {
|
|
360
|
+
if (!channel || !parentChannel) return;
|
|
361
|
+
try { await parentChannel.closeTopic(channel.cid); } catch (e) { console.error('Error closing topic', e); }
|
|
362
|
+
}, [channel, parentChannel]);
|
|
363
|
+
|
|
364
|
+
const handleReopenTopic = useCallback(async () => {
|
|
365
|
+
if (!channel || !parentChannel) return;
|
|
366
|
+
try { await parentChannel.reopenTopic(channel.cid); } catch (e) { console.error('Error reopening topic', e); }
|
|
367
|
+
}, [channel, parentChannel]);
|
|
368
|
+
|
|
296
369
|
const { members } = useChannelMembers(channel);
|
|
297
370
|
const { channelName, channelImage, channelDescription } = useChannelProfile(channel);
|
|
298
371
|
|
|
299
372
|
const [showAddMemberModal, setShowAddMemberModal] = useState(false);
|
|
300
373
|
const [showEditChannelModal, setShowEditChannelModal] = useState(false);
|
|
374
|
+
const [showEditTopicModal, setShowEditTopicModal] = useState(false);
|
|
301
375
|
const [showSearchPanel, setShowSearchPanel] = useState(false);
|
|
302
376
|
const [showSettingsPanel, setShowSettingsPanel] = useState(false);
|
|
303
377
|
|
|
304
378
|
// Permission: only owner or moderator can edit channel info (banned users cannot)
|
|
305
|
-
const canEditChannel = isTeamChannel && !isBanned && (currentUserRole === 'owner' || currentUserRole === 'moder');
|
|
379
|
+
const canEditChannel = (isTeamChannel || isTopic) && !isBanned && (currentUserRole === 'owner' || currentUserRole === 'moder');
|
|
306
380
|
|
|
307
381
|
const handleEditChannelClick = useCallback(() => {
|
|
308
|
-
|
|
309
|
-
|
|
382
|
+
if (isTopic) {
|
|
383
|
+
setShowEditTopicModal(true);
|
|
384
|
+
} else {
|
|
385
|
+
setShowEditChannelModal(true);
|
|
386
|
+
}
|
|
387
|
+
}, [isTopic]);
|
|
310
388
|
|
|
311
389
|
const handleAddMemberClick = useCallback(() => {
|
|
312
390
|
if (onAddMemberClick) return onAddMemberClick();
|
|
@@ -330,19 +408,20 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
330
408
|
onEditClick={handleEditChannelClick}
|
|
331
409
|
isPublic={Boolean(channel?.data?.public)}
|
|
332
410
|
isTeamChannel={isTeamChannel}
|
|
411
|
+
parentChannelName={parentChannelName}
|
|
412
|
+
isTopic={isTopic}
|
|
333
413
|
/>
|
|
334
414
|
|
|
335
|
-
{isBanned
|
|
415
|
+
{isBanned && (
|
|
336
416
|
<div className="ermis-channel-info__banned-banner">
|
|
337
|
-
<
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
</div>
|
|
343
|
-
<span className="ermis-channel-info__banned-banner-text">You have been blocked from this channel</span>
|
|
417
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
418
|
+
<circle cx="12" cy="12" r="10"></circle>
|
|
419
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
|
|
420
|
+
</svg>
|
|
421
|
+
<span className="ermis-channel-info__banned-banner-text">You have been banned from this channel</span>
|
|
344
422
|
</div>
|
|
345
|
-
)
|
|
423
|
+
)}
|
|
424
|
+
{!isBanned && (
|
|
346
425
|
<>
|
|
347
426
|
<ActionsComponent
|
|
348
427
|
onSearchClick={() => setShowSearchPanel(true)}
|
|
@@ -351,7 +430,11 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
351
430
|
onDeleteChannel={handleDeleteChannel}
|
|
352
431
|
onBlockUser={handleBlockUser}
|
|
353
432
|
onUnblockUser={handleUnblockUser}
|
|
433
|
+
onCloseTopic={handleCloseTopic}
|
|
434
|
+
onReopenTopic={handleReopenTopic}
|
|
354
435
|
isTeamChannel={isTeamChannel}
|
|
436
|
+
isTopic={isTopic}
|
|
437
|
+
isClosedTopic={isClosedTopic}
|
|
355
438
|
isBlocked={isBlocked}
|
|
356
439
|
currentUserRole={currentUserRole}
|
|
357
440
|
searchLabel={actionsSearchLabel}
|
|
@@ -360,6 +443,8 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
360
443
|
leaveLabel={actionsLeaveLabel}
|
|
361
444
|
blockLabel={actionsBlockLabel}
|
|
362
445
|
unblockLabel={actionsUnblockLabel}
|
|
446
|
+
closeTopicLabel={actionsCloseTopicLabel}
|
|
447
|
+
reopenTopicLabel={actionsReopenTopicLabel}
|
|
363
448
|
/>
|
|
364
449
|
|
|
365
450
|
<TabsComponent
|
|
@@ -427,6 +512,16 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
427
512
|
/>
|
|
428
513
|
);
|
|
429
514
|
})()}
|
|
515
|
+
|
|
516
|
+
{showEditTopicModal && (() => {
|
|
517
|
+
return (
|
|
518
|
+
<TopicModal
|
|
519
|
+
isOpen={true}
|
|
520
|
+
onClose={() => setShowEditTopicModal(false)}
|
|
521
|
+
topic={channel}
|
|
522
|
+
/>
|
|
523
|
+
);
|
|
524
|
+
})()}
|
|
430
525
|
</>
|
|
431
526
|
)}
|
|
432
527
|
|
|
@@ -446,6 +541,9 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
|
|
|
446
541
|
isOpen={showSettingsPanel}
|
|
447
542
|
onClose={() => setShowSettingsPanel(false)}
|
|
448
543
|
channel={channel}
|
|
544
|
+
workspaceTopicsTitle={settingsWorkspaceTopicsTitle}
|
|
545
|
+
topicsFeatureName={settingsTopicsFeatureName}
|
|
546
|
+
topicsFeatureDescription={settingsTopicsFeatureDescription}
|
|
449
547
|
/>
|
|
450
548
|
)}
|
|
451
549
|
</div>
|
|
@@ -32,10 +32,18 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
32
32
|
LoadingComponent,
|
|
33
33
|
}) => {
|
|
34
34
|
const isMessaging = channel?.type === 'messaging';
|
|
35
|
+
const isTopic = Boolean(channel?.data?.parent_cid);
|
|
36
|
+
|
|
35
37
|
const { isBanned } = useBannedState(channel, currentUserId);
|
|
36
38
|
const { isBlocked } = useBlockedState(channel, currentUserId);
|
|
37
39
|
|
|
38
|
-
const availableTabs: MediaTab[] =
|
|
40
|
+
const availableTabs: MediaTab[] = useMemo(() => {
|
|
41
|
+
let tabs = isMessaging ? MESSAGING_TABS : ALL_TABS;
|
|
42
|
+
if (isTopic) {
|
|
43
|
+
tabs = tabs.filter(t => t !== 'members');
|
|
44
|
+
}
|
|
45
|
+
return tabs;
|
|
46
|
+
}, [isMessaging, isTopic]);
|
|
39
47
|
|
|
40
48
|
const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
|
|
41
49
|
const contentTab = useDeferredValue(activeTab);
|
|
@@ -45,7 +53,7 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
45
53
|
useEffect(() => {
|
|
46
54
|
setActiveTab(availableTabs[0]);
|
|
47
55
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
48
|
-
}, [channel?.cid]);
|
|
56
|
+
}, [channel?.cid, availableTabs]);
|
|
49
57
|
|
|
50
58
|
// Resolve sub-components with defaults
|
|
51
59
|
const MemberItem = MemberItemComponent || MemberListItem;
|