@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.
Files changed (43) hide show
  1. package/dist/index.cjs +2411 -1309
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +471 -16
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +145 -1
  6. package/dist/index.d.ts +145 -1
  7. package/dist/index.mjs +2340 -1242
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/components/BannedOverlay.tsx +40 -0
  11. package/src/components/ChannelActions.tsx +231 -0
  12. package/src/components/ChannelHeader.tsx +38 -2
  13. package/src/components/ChannelInfo/ChannelInfo.tsx +118 -20
  14. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +10 -2
  15. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +88 -1
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -4
  17. package/src/components/ChannelList.tsx +467 -45
  18. package/src/components/ClosedTopicOverlay.tsx +38 -0
  19. package/src/components/MessageInput.tsx +19 -2
  20. package/src/components/MessageItem.tsx +8 -11
  21. package/src/components/MessageQuickReactions.tsx +3 -2
  22. package/src/components/MessageReactions.tsx +8 -3
  23. package/src/components/MessageRenderers.tsx +7 -9
  24. package/src/components/PendingOverlay.tsx +41 -0
  25. package/src/components/TopicModal.tsx +189 -0
  26. package/src/components/VirtualMessageList.tsx +74 -43
  27. package/src/hooks/useBannedState.ts +27 -3
  28. package/src/hooks/useChannelCapabilities.ts +7 -3
  29. package/src/hooks/useChannelData.ts +1 -1
  30. package/src/hooks/useChannelListUpdates.ts +24 -3
  31. package/src/hooks/useChannelRowUpdates.ts +6 -0
  32. package/src/hooks/useMessageActions.ts +1 -1
  33. package/src/index.ts +6 -1
  34. package/src/styles/_channel-info.css +21 -0
  35. package/src/styles/_channel-list.css +217 -6
  36. package/src/styles/_message-bubble.css +75 -9
  37. package/src/styles/_message-input.css +24 -0
  38. package/src/styles/_message-list.css +51 -6
  39. package/src/styles/_message-quick-reactions.css +5 -0
  40. package/src/styles/_message-reactions.css +7 -0
  41. package/src/styles/_topic-modal.css +154 -0
  42. package/src/styles/index.css +1 -0
  43. 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.5",
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.5",
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
- <AvatarComponent image={channelImage} name={channelName} size={32} />
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-header__name">{channelName}</div>
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
- <AvatarComponent image={channelImage} name={channelName} size={80} className="ermis-channel-info__avatar" />
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 = 'Channel Info',
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
- setShowEditChannelModal(true);
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
- <div className="ermis-channel-info__banned-banner-icon">
338
- <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
339
- <circle cx="12" cy="12" r="10" />
340
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
341
- </svg>
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[] = isMessaging ? MESSAGING_TABS : ALL_TABS;
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;