@ermis-network/ermis-chat-react 1.0.6 → 1.0.8

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 (62) hide show
  1. package/dist/index.cjs +3802 -1772
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +836 -25
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +304 -1
  6. package/dist/index.d.ts +304 -1
  7. package/dist/index.mjs +3755 -1761
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/channelRoleUtils.ts +73 -0
  11. package/src/channelTypeUtils.ts +46 -0
  12. package/src/components/Avatar.tsx +57 -31
  13. package/src/components/BannedOverlay.tsx +40 -0
  14. package/src/components/ChannelActions.tsx +233 -0
  15. package/src/components/ChannelHeader.tsx +126 -5
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
  20. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  21. package/src/components/ChannelList.tsx +514 -47
  22. package/src/components/ClosedTopicOverlay.tsx +38 -0
  23. package/src/components/CreateChannelModal.tsx +53 -16
  24. package/src/components/EditPreview.tsx +2 -1
  25. package/src/components/ForwardMessageModal.tsx +2 -1
  26. package/src/components/MediaLightbox.tsx +314 -0
  27. package/src/components/MessageInput.tsx +21 -3
  28. package/src/components/MessageItem.tsx +10 -12
  29. package/src/components/MessageQuickReactions.tsx +3 -2
  30. package/src/components/MessageReactions.tsx +8 -3
  31. package/src/components/MessageRenderers.tsx +174 -54
  32. package/src/components/PendingOverlay.tsx +51 -0
  33. package/src/components/PinnedMessages.tsx +2 -1
  34. package/src/components/ReplyPreview.tsx +2 -1
  35. package/src/components/SkippedOverlay.tsx +36 -0
  36. package/src/components/TopicModal.tsx +189 -0
  37. package/src/components/UserPicker.tsx +1 -1
  38. package/src/components/VirtualMessageList.tsx +162 -47
  39. package/src/hooks/useBannedState.ts +27 -3
  40. package/src/hooks/useBlockedState.ts +3 -2
  41. package/src/hooks/useChannelCapabilities.ts +10 -8
  42. package/src/hooks/useChannelData.ts +1 -1
  43. package/src/hooks/useChannelListUpdates.ts +28 -5
  44. package/src/hooks/useChannelMessages.ts +2 -3
  45. package/src/hooks/useChannelRowUpdates.ts +9 -2
  46. package/src/hooks/useMessageActions.ts +23 -9
  47. package/src/hooks/useOnlineStatus.ts +71 -0
  48. package/src/hooks/useOnlineUsers.ts +115 -0
  49. package/src/hooks/usePendingState.ts +8 -3
  50. package/src/index.ts +67 -10
  51. package/src/messageTypeUtils.ts +64 -0
  52. package/src/styles/_channel-info.css +21 -0
  53. package/src/styles/_channel-list.css +276 -6
  54. package/src/styles/_media-lightbox.css +263 -0
  55. package/src/styles/_message-bubble.css +170 -13
  56. package/src/styles/_message-input.css +24 -0
  57. package/src/styles/_message-list.css +76 -6
  58. package/src/styles/_message-quick-reactions.css +5 -0
  59. package/src/styles/_message-reactions.css +7 -0
  60. package/src/styles/_topic-modal.css +154 -0
  61. package/src/styles/index.css +2 -0
  62. package/src/types.ts +203 -3
@@ -3,7 +3,11 @@ import { useChatClient } from '../hooks/useChatClient';
3
3
  import { usePendingState } from '../hooks/usePendingState';
4
4
  import { Avatar } from './Avatar';
5
5
  import type { ChannelHeaderProps } from '../types';
6
+ import type { OnlineStatus } from '../hooks/useOnlineStatus';
6
7
  import { ErmisCallContext } from '../context/ErmisCallContext';
8
+ import { hasTopicsEnabled, isDirectChannel } from '../channelTypeUtils';
9
+ import { isSkippedMember, isFriendChannel } from '../channelRoleUtils';
10
+ import type { Event } from '@ermis-network/ermis-chat-sdk';
7
11
 
8
12
  export type { ChannelHeaderProps } from '../types';
9
13
 
@@ -16,6 +20,8 @@ export type { ChannelHeaderProps } from '../types';
16
20
  * - `AvatarComponent` — replace the avatar
17
21
  * - `renderTitle(channel)` — fully custom title rendering
18
22
  * - `renderRight(channel)` — render content on the right side
23
+ * - `showOnlineStatus` — show online/offline dot for friend channels (default: true)
24
+ * - `OnlineIndicatorComponent` — replace the default indicator
19
25
  *
20
26
  * For a fully custom header, use `Channel`'s `HeaderComponent` prop instead.
21
27
  */
@@ -32,12 +38,21 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
32
38
  audioCallTitle = 'Audio Call',
33
39
  videoCallTitle = 'Video Call',
34
40
  CallBadgeComponent,
41
+ showOnlineStatus = true,
42
+ onlineLabel = 'Online',
43
+ offlineLabel = 'Offline',
44
+ OnlineIndicatorComponent,
35
45
  }) => {
36
46
  const { activeChannel, client, enableCall } = useChatClient();
37
47
  const { isPending } = usePendingState(activeChannel, client.userID);
38
48
  const callContext = useContext(ErmisCallContext);
39
49
 
40
- const actionDisabled = isPending;
50
+ const isSkipped = client.userID
51
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
52
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
53
+ : false;
54
+
55
+ const actionDisabled = isPending || isSkipped;
41
56
 
42
57
  // Force re-render when channel.updated WS event fires
43
58
  const [channelUpdateCount, setChannelUpdateCount] = useState(0);
@@ -60,26 +75,132 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
60
75
  [image, activeChannel?.data?.image, channelUpdateCount],
61
76
  );
62
77
 
78
+ const teamName = useMemo(() => {
79
+ if (!activeChannel) return undefined;
80
+
81
+ // If it's a topic, derive from parent_cid
82
+ const parentCid = activeChannel.data?.parent_cid as string | undefined;
83
+ if (parentCid && client.activeChannels[parentCid]) {
84
+ return client.activeChannels[parentCid].data?.name || client.activeChannels[parentCid].cid;
85
+ }
86
+
87
+ // If it's a topics-enabled team channel (the general proxy), the proxy overrides data.name.
88
+ // We can pull the original name from the SDK cache.
89
+ if (hasTopicsEnabled(activeChannel)) {
90
+ const rawChannel = client.activeChannels[activeChannel.cid];
91
+ if (rawChannel && rawChannel.data?.name && rawChannel.data.name !== activeChannel.data?.name) {
92
+ return rawChannel.data.name;
93
+ }
94
+ }
95
+
96
+ return undefined;
97
+ }, [activeChannel, client.activeChannels]);
98
+
99
+ // ── Online Status (direct friend channels only) ──
100
+ const currentUserId = client.userID;
101
+
102
+ // Get the "other" user's ID from the direct channel.
103
+ const otherUserId = useMemo(() => {
104
+ if (!activeChannel || !currentUserId || !isDirectChannel(activeChannel)) return undefined;
105
+ const members = activeChannel.state?.members;
106
+ if (!members) return undefined;
107
+ for (const memberId of Object.keys(members)) {
108
+ if (memberId !== currentUserId) return memberId;
109
+ }
110
+ return undefined;
111
+ }, [activeChannel, currentUserId]);
112
+
113
+ // Check if this is a friend channel (both members are owner).
114
+ const isFriend = useMemo(() => {
115
+ if (!otherUserId || !currentUserId || !activeChannel) return false;
116
+ return isFriendChannel(activeChannel, otherUserId, currentUserId);
117
+ }, [activeChannel, otherUserId, currentUserId]);
118
+
119
+ // Derive online status from watchers + subscribe to realtime events.
120
+ const [onlineStatus, setOnlineStatus] = useState<OnlineStatus>('unknown');
121
+
122
+ useEffect(() => {
123
+ if (!showOnlineStatus || !isFriend || !otherUserId || !activeChannel) {
124
+ setOnlineStatus('unknown');
125
+ return;
126
+ }
127
+
128
+ // Read initial state from watchers.
129
+ setOnlineStatus(activeChannel.state?.watchers?.[otherUserId] ? 'online' : 'offline');
130
+
131
+ const handleWatchingStart = (event: Event) => {
132
+ if (event.user?.id === otherUserId) {
133
+ setOnlineStatus('online');
134
+ }
135
+ };
136
+
137
+ const handleWatchingStop = (event: Event) => {
138
+ if (event.user?.id === otherUserId) {
139
+ setOnlineStatus('offline');
140
+ }
141
+ };
142
+
143
+ const sub1 = activeChannel.on('user.watching.start', handleWatchingStart);
144
+ const sub2 = activeChannel.on('user.watching.stop', handleWatchingStop);
145
+
146
+ return () => {
147
+ sub1.unsubscribe();
148
+ sub2.unsubscribe();
149
+ };
150
+ }, [activeChannel, otherUserId, isFriend, showOnlineStatus]);
151
+
152
+ const showOnlineDot = showOnlineStatus && onlineStatus !== 'unknown';
153
+ const isOnline = onlineStatus === 'online';
154
+
63
155
  if (!activeChannel) return null;
64
156
 
65
157
  return (
66
158
  <div className={`ermis-channel-header${className ? ` ${className}` : ''}`}>
67
- <AvatarComponent image={channelImage} name={channelName} size={32} />
159
+ {activeChannel.data?.parent_cid ? (
160
+ <div className="ermis-channel-header__topic-avatar">
161
+ {channelImage && typeof channelImage === 'string' && channelImage.startsWith('emoji://')
162
+ ? channelImage.replace('emoji://', '')
163
+ : '#'}
164
+ </div>
165
+ ) : (
166
+ <AvatarComponent image={channelImage} name={teamName || channelName} size={32} />
167
+ )}
68
168
 
69
169
  <div className="ermis-channel-header__info">
70
170
  {renderTitle ? (
71
171
  renderTitle(activeChannel)
72
172
  ) : (
73
- <div className="ermis-channel-header__name">{channelName}</div>
173
+ <div className="ermis-channel-header__title-container">
174
+ {teamName && (
175
+ <div className="ermis-channel-header__team-name">
176
+ {teamName}
177
+ </div>
178
+ )}
179
+ <div className="ermis-channel-header__name">{channelName}</div>
180
+ </div>
181
+ )}
182
+ {/* Online/Offline indicator for friend direct channels */}
183
+ {showOnlineDot && (
184
+ OnlineIndicatorComponent ? (
185
+ <OnlineIndicatorComponent isOnline={isOnline} />
186
+ ) : (
187
+ <div className={`ermis-channel-header__online-status ermis-channel-header__online-status--${isOnline ? 'online' : 'offline'}`}>
188
+ <span className={`ermis-channel-header__online-dot ermis-channel-header__online-dot--${isOnline ? 'online' : 'offline'}`} />
189
+ <span className="ermis-channel-header__online-label">
190
+ {isOnline ? onlineLabel : offlineLabel}
191
+ </span>
192
+ </div>
193
+ )
74
194
  )}
75
- {subtitle && (
195
+ {/* Consumer-provided subtitle (takes over if set) */}
196
+ {subtitle && !showOnlineDot && (
76
197
  <div className="ermis-channel-header__subtitle">{subtitle}</div>
77
198
  )}
78
199
  </div>
79
200
 
80
201
  {/* renderRight exposes actionDisabled for consumers to disable UI features natively */}
81
202
  <div className="ermis-channel-header__actions">
82
- {enableCall && callContext && activeChannel?.type === 'messaging' && !isPending && (
203
+ {enableCall && callContext && isDirectChannel(activeChannel) && !isPending && !isSkipped && (
83
204
  <>
84
205
  {renderAudioCallButton ? (
85
206
  renderAudioCallButton(() => callContext.createCall('audio', activeChannel.cid || ''), actionDisabled)
@@ -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 {
@@ -15,6 +16,8 @@ import type {
15
16
  ChannelInfoActionsProps,
16
17
  } from '../../types';
17
18
  import { useChannelMembers, useChannelProfile } from '../../hooks/useChannelData';
19
+ import { isGroupChannel, isTopicChannel } from '../../channelTypeUtils';
20
+ import { canManageChannel, CHANNEL_ROLES } from '../../channelRoleUtils';
18
21
 
19
22
  export const DefaultChannelInfoHeader: React.FC<ChannelInfoHeaderProps> = React.memo(({ title, onClose }) => {
20
23
  return (
@@ -32,10 +35,22 @@ export const DefaultChannelInfoHeader: React.FC<ChannelInfoHeaderProps> = React.
32
35
  });
33
36
  DefaultChannelInfoHeader.displayName = 'DefaultChannelInfoHeader';
34
37
 
35
- export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.memo(({ channelName, channelImage, channelDescription, AvatarComponent, canEdit, onEditClick, isPublic, isTeamChannel }) => {
38
+ export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.memo(({ channelName, channelImage, channelDescription, AvatarComponent, canEdit, onEditClick, isPublic, isTeamChannel, parentChannelName, isTopic }) => {
39
+ const renderAvatar = () => {
40
+ if (isTopic && channelImage && channelImage.startsWith('emoji://')) {
41
+ const emoji = channelImage.replace('emoji://', '');
42
+ return (
43
+ <div className="ermis-channel-info__topic-emoji-avatar">
44
+ {emoji}
45
+ </div>
46
+ );
47
+ }
48
+ return <AvatarComponent image={channelImage} name={channelName} size={80} className="ermis-channel-info__avatar" />;
49
+ };
50
+
36
51
  return (
37
52
  <div className="ermis-channel-info__cover">
38
- <AvatarComponent image={channelImage} name={channelName} size={80} className="ermis-channel-info__avatar" />
53
+ {renderAvatar()}
39
54
  <div className="ermis-channel-info__name-row">
40
55
  <h2 className="ermis-channel-info__name">{channelName}</h2>
41
56
  {canEdit && onEditClick && (
@@ -47,6 +62,11 @@ export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.me
47
62
  </button>
48
63
  )}
49
64
  </div>
65
+ {parentChannelName && (
66
+ <div className="ermis-channel-info__parent-name">
67
+ {parentChannelName}
68
+ </div>
69
+ )}
50
70
  {isTeamChannel && (
51
71
  <span className={`ermis-channel-info__type-badge ${isPublic ? 'ermis-channel-info__type-badge--public' : 'ermis-channel-info__type-badge--private'}`}>
52
72
  {isPublic ? (
@@ -74,10 +94,10 @@ DefaultChannelInfoCover.displayName = 'DefaultChannelInfoCover';
74
94
 
75
95
  export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = React.memo(({
76
96
  onSearchClick, onSettingsClick, onLeaveChannel, onDeleteChannel,
77
- onBlockUser, onUnblockUser,
78
- isTeamChannel, isBlocked, currentUserRole,
97
+ onBlockUser, onUnblockUser, onCloseTopic, onReopenTopic,
98
+ isTeamChannel, isTopic, isClosedTopic, isBlocked, currentUserRole,
79
99
  searchLabel = 'Search', settingsLabel = 'Settings', deleteLabel = 'Delete', leaveLabel = 'Leave',
80
- blockLabel = 'Block', unblockLabel = 'Unblock'
100
+ blockLabel = 'Block', unblockLabel = 'Unblock', closeTopicLabel = 'Close Topic', reopenTopicLabel = 'Reopen Topic'
81
101
  }) => {
82
102
  return (
83
103
  <div className="ermis-channel-info__actions">
@@ -90,7 +110,7 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
90
110
  </div>
91
111
  <span>{searchLabel}</span>
92
112
  </button>
93
- {isTeamChannel && (currentUserRole === 'owner' || currentUserRole === 'moder') && (
113
+ {isTeamChannel && canManageChannel(currentUserRole) && (
94
114
  <button className="ermis-channel-info__action-btn" onClick={onSettingsClick}>
95
115
  <div className="ermis-channel-info__action-icon">
96
116
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -102,7 +122,7 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
102
122
  </button>
103
123
  )}
104
124
  {isTeamChannel && (
105
- currentUserRole === 'owner' ? (
125
+ currentUserRole === CHANNEL_ROLES.OWNER ? (
106
126
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onDeleteChannel}>
107
127
  <div className="ermis-channel-info__action-icon">
108
128
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -125,8 +145,32 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
125
145
  </button>
126
146
  )
127
147
  )}
148
+ {/* Topics: Close/Reopen Topic for owner/moder */}
149
+ {isTopic && canManageChannel(currentUserRole) && (
150
+ isClosedTopic ? (
151
+ <button className="ermis-channel-info__action-btn" onClick={onReopenTopic}>
152
+ <div className="ermis-channel-info__action-icon">
153
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
154
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
155
+ <path d="M7 11V7a5 5 0 0 1 9.9-1" />
156
+ </svg>
157
+ </div>
158
+ <span>{reopenTopicLabel}</span>
159
+ </button>
160
+ ) : (
161
+ <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onCloseTopic}>
162
+ <div className="ermis-channel-info__action-icon">
163
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
164
+ <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
165
+ <path d="M7 11V7a5 5 0 0 1 10 0v4" />
166
+ </svg>
167
+ </div>
168
+ <span>{closeTopicLabel}</span>
169
+ </button>
170
+ )
171
+ )}
128
172
  {/* Block/Unblock — messaging (1-1) channels only */}
129
- {!isTeamChannel && (
173
+ {!isTeamChannel && !isTopic && (
130
174
  isBlocked ? (
131
175
  <button className="ermis-channel-info__action-btn" onClick={onUnblockUser}>
132
176
  <div className="ermis-channel-info__action-icon">
@@ -160,7 +204,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
160
204
  className = '',
161
205
  AvatarComponent = Avatar,
162
206
  onClose,
163
- title = 'Channel Info',
207
+ title: titleProp,
164
208
  HeaderComponent = DefaultChannelInfoHeader,
165
209
  CoverComponent = DefaultChannelInfoCover,
166
210
  ActionsComponent = DefaultChannelInfoActions,
@@ -216,6 +260,12 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
216
260
  onUnblockUser: onUnblockUserProp,
217
261
  actionsBlockLabel,
218
262
  actionsUnblockLabel,
263
+ actionsCloseTopicLabel,
264
+ actionsReopenTopicLabel,
265
+ // Settings panel customizations
266
+ settingsWorkspaceTopicsTitle,
267
+ settingsTopicsFeatureName,
268
+ settingsTopicsFeatureDescription,
219
269
  } = props;
220
270
 
221
271
  const { activeChannel, client } = useChatClient();
@@ -225,7 +275,14 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
225
275
 
226
276
  const currentUserId = client?.userID;
227
277
  const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
228
- const isTeamChannel = channel?.type === 'team';
278
+ const isTeamChannel = isGroupChannel(channel);
279
+ const isTopic = isTopicChannel(channel);
280
+ const isClosedTopic = channel?.data?.is_closed_topic === true;
281
+ const title = titleProp !== undefined ? titleProp : (isTopic ? 'Topic Info' : 'Channel Info');
282
+
283
+ const parentCid = channel?.data?.parent_cid as string | undefined;
284
+ const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
285
+ let parentChannelName = parentChannel?.data?.name || (parentCid ? 'Unknown' : undefined);
229
286
 
230
287
  const handleDeleteChannel = useCallback(async () => {
231
288
  if (onDeleteChannelProp) return onDeleteChannelProp();
@@ -293,20 +350,47 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
293
350
  try { await channel.unblockUser(); } catch (e) { console.error('Error unblocking user', e); }
294
351
  }, [channel, onUnblockUserProp]);
295
352
 
353
+ const handleCloseTopic = useCallback(async () => {
354
+ if (!channel || !parentChannel) return;
355
+ try { await parentChannel.closeTopic(channel.cid); } catch (e) { console.error('Error closing topic', e); }
356
+ }, [channel, parentChannel]);
357
+
358
+ const handleReopenTopic = useCallback(async () => {
359
+ if (!channel || !parentChannel) return;
360
+ try { await parentChannel.reopenTopic(channel.cid); } catch (e) { console.error('Error reopening topic', e); }
361
+ }, [channel, parentChannel]);
362
+
296
363
  const { members } = useChannelMembers(channel);
297
- const { channelName, channelImage, channelDescription } = useChannelProfile(channel);
364
+ const { channelName: profileChannelName, channelImage, channelDescription } = useChannelProfile(channel);
365
+
366
+ let finalChannelName = profileChannelName;
367
+ let finalParentChannelName = parentChannelName;
368
+
369
+ // If this is the proxy 'general' channel, show the team name as the main name and hide the parent name.
370
+ if (isGroupChannel(channel) && channel?.data?.name === 'general' && channel.cid) {
371
+ const realChannelName = client?.activeChannels[channel.cid]?.data?.name;
372
+ if (realChannelName && realChannelName !== 'general') {
373
+ finalChannelName = realChannelName;
374
+ finalParentChannelName = undefined;
375
+ }
376
+ }
298
377
 
299
378
  const [showAddMemberModal, setShowAddMemberModal] = useState(false);
300
379
  const [showEditChannelModal, setShowEditChannelModal] = useState(false);
380
+ const [showEditTopicModal, setShowEditTopicModal] = useState(false);
301
381
  const [showSearchPanel, setShowSearchPanel] = useState(false);
302
382
  const [showSettingsPanel, setShowSettingsPanel] = useState(false);
303
383
 
304
384
  // Permission: only owner or moderator can edit channel info (banned users cannot)
305
- const canEditChannel = isTeamChannel && !isBanned && (currentUserRole === 'owner' || currentUserRole === 'moder');
385
+ const canEditChannel = (isTeamChannel || isTopic) && !isBanned && canManageChannel(currentUserRole);
306
386
 
307
387
  const handleEditChannelClick = useCallback(() => {
308
- setShowEditChannelModal(true);
309
- }, []);
388
+ if (isTopic) {
389
+ setShowEditTopicModal(true);
390
+ } else {
391
+ setShowEditChannelModal(true);
392
+ }
393
+ }, [isTopic]);
310
394
 
311
395
  const handleAddMemberClick = useCallback(() => {
312
396
  if (onAddMemberClick) return onAddMemberClick();
@@ -322,7 +406,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
322
406
  <HeaderComponent title={title} onClose={onClose} />
323
407
 
324
408
  <CoverComponent
325
- channelName={channelName}
409
+ channelName={finalChannelName}
326
410
  channelImage={channelImage}
327
411
  channelDescription={channelDescription}
328
412
  AvatarComponent={AvatarComponent}
@@ -330,19 +414,20 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
330
414
  onEditClick={handleEditChannelClick}
331
415
  isPublic={Boolean(channel?.data?.public)}
332
416
  isTeamChannel={isTeamChannel}
417
+ parentChannelName={finalParentChannelName}
418
+ isTopic={isTopic}
333
419
  />
334
420
 
335
- {isBanned ? (
421
+ {isBanned && (
336
422
  <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>
423
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
424
+ <circle cx="12" cy="12" r="10"></circle>
425
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
426
+ </svg>
427
+ <span className="ermis-channel-info__banned-banner-text">You have been banned from this channel</span>
344
428
  </div>
345
- ) : (
429
+ )}
430
+ {!isBanned && (
346
431
  <>
347
432
  <ActionsComponent
348
433
  onSearchClick={() => setShowSearchPanel(true)}
@@ -351,7 +436,11 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
351
436
  onDeleteChannel={handleDeleteChannel}
352
437
  onBlockUser={handleBlockUser}
353
438
  onUnblockUser={handleUnblockUser}
439
+ onCloseTopic={handleCloseTopic}
440
+ onReopenTopic={handleReopenTopic}
354
441
  isTeamChannel={isTeamChannel}
442
+ isTopic={isTopic}
443
+ isClosedTopic={isClosedTopic}
355
444
  isBlocked={isBlocked}
356
445
  currentUserRole={currentUserRole}
357
446
  searchLabel={actionsSearchLabel}
@@ -360,6 +449,8 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
360
449
  leaveLabel={actionsLeaveLabel}
361
450
  blockLabel={actionsBlockLabel}
362
451
  unblockLabel={actionsUnblockLabel}
452
+ closeTopicLabel={actionsCloseTopicLabel}
453
+ reopenTopicLabel={actionsReopenTopicLabel}
363
454
  />
364
455
 
365
456
  <TabsComponent
@@ -427,6 +518,16 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
427
518
  />
428
519
  );
429
520
  })()}
521
+
522
+ {showEditTopicModal && (() => {
523
+ return (
524
+ <TopicModal
525
+ isOpen={true}
526
+ onClose={() => setShowEditTopicModal(false)}
527
+ topic={channel}
528
+ />
529
+ );
530
+ })()}
430
531
  </>
431
532
  )}
432
533
 
@@ -446,6 +547,9 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
446
547
  isOpen={showSettingsPanel}
447
548
  onClose={() => setShowSettingsPanel(false)}
448
549
  channel={channel}
550
+ workspaceTopicsTitle={settingsWorkspaceTopicsTitle}
551
+ topicsFeatureName={settingsTopicsFeatureName}
552
+ topicsFeatureDescription={settingsTopicsFeatureDescription}
449
553
  />
450
554
  )}
451
555
  </div>
@@ -8,7 +8,16 @@ import { LinkListItem } from './LinkListItem';
8
8
  import { FileListItem } from './FileListItem';
9
9
  import { MemberListItem } from './MemberListItem';
10
10
  import { TabEmptyState, TabLoadingState } from './States';
11
- import type { ChannelInfoTabsProps, MediaTab, AttachmentItem } from '../../types';
11
+ import { MediaLightbox } from '../MediaLightbox';
12
+ import type { ChannelInfoTabsProps, MediaTab, AttachmentItem, MediaLightboxItem } from '../../types';
13
+ import { isDirectChannel } from '../../channelTypeUtils';
14
+ import {
15
+ CHANNEL_ROLES,
16
+ canRemoveTargetMember,
17
+ canBanTargetMember,
18
+ canPromoteTargetMember,
19
+ canDemoteTargetMember
20
+ } from '../../channelRoleUtils';
12
21
 
13
22
  export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo(({
14
23
  channel,
@@ -31,11 +40,19 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
31
40
  EmptyStateComponent,
32
41
  LoadingComponent,
33
42
  }) => {
34
- const isMessaging = channel?.type === 'messaging';
43
+ const isMessaging = isDirectChannel(channel);
44
+ const isTopic = Boolean(channel?.data?.parent_cid);
45
+
35
46
  const { isBanned } = useBannedState(channel, currentUserId);
36
47
  const { isBlocked } = useBlockedState(channel, currentUserId);
37
48
 
38
- const availableTabs: MediaTab[] = isMessaging ? MESSAGING_TABS : ALL_TABS;
49
+ const availableTabs: MediaTab[] = useMemo(() => {
50
+ let tabs = isMessaging ? MESSAGING_TABS : ALL_TABS;
51
+ if (isTopic) {
52
+ tabs = tabs.filter(t => t !== 'members');
53
+ }
54
+ return tabs;
55
+ }, [isMessaging, isTopic]);
39
56
 
40
57
  const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
41
58
  const contentTab = useDeferredValue(activeTab);
@@ -45,7 +62,7 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
45
62
  useEffect(() => {
46
63
  setActiveTab(availableTabs[0]);
47
64
  // eslint-disable-next-line react-hooks/exhaustive-deps
48
- }, [channel?.cid]);
65
+ }, [channel?.cid, availableTabs]);
49
66
 
50
67
  // Resolve sub-components with defaults
51
68
  const MemberItem = MemberItemComponent || MemberListItem;
@@ -60,8 +77,8 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
60
77
 
61
78
  const sortedMembers = useMemo(() => {
62
79
  return [...members].sort((a, b) => {
63
- const aWeight = ROLE_WEIGHTS[a.channel_role || 'member'] || 0;
64
- const bWeight = ROLE_WEIGHTS[b.channel_role || 'member'] || 0;
80
+ const aWeight = ROLE_WEIGHTS[a.channel_role || CHANNEL_ROLES.MEMBER] || 0;
81
+ const bWeight = ROLE_WEIGHTS[b.channel_role || CHANNEL_ROLES.MEMBER] || 0;
65
82
  return bWeight - aWeight;
66
83
  });
67
84
  }, [members]);
@@ -125,6 +142,31 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
125
142
  window.open(url, '_blank', 'noopener,noreferrer');
126
143
  }, []);
127
144
 
145
+ // Lightbox state for media tab
146
+ const [lightboxOpen, setLightboxOpen] = useState(false);
147
+ const [lightboxIndex, setLightboxIndex] = useState(0);
148
+
149
+ const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
150
+ return mediaItems.map(item => ({
151
+ type: (item.attachment_type === 'video' ? 'video' : 'image') as 'image' | 'video',
152
+ src: item.url,
153
+ alt: item.file_name,
154
+ posterSrc: item.thumb_url || undefined,
155
+ }));
156
+ }, [mediaItems]);
157
+
158
+ const handleMediaClick = useCallback((url: string) => {
159
+ const idx = mediaItems.findIndex(item => item.url === url);
160
+ if (idx >= 0) {
161
+ setLightboxIndex(idx);
162
+ setLightboxOpen(true);
163
+ }
164
+ }, [mediaItems]);
165
+
166
+ const closeLightbox = useCallback(() => {
167
+ setLightboxOpen(false);
168
+ }, []);
169
+
128
170
  // Group media into rows of 3 for grid layout inside VList
129
171
  const mediaRows = useMemo(() => {
130
172
  const rows: AttachmentItem[][] = [];
@@ -163,42 +205,29 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
163
205
  }
164
206
  }
165
207
  sortedMembers.forEach(member => {
166
- const role = member.channel_role || 'member';
167
- const isTargetRemovable = role === 'member' || role === 'pending' || (currentUserRole === 'owner' && role === 'moder');
208
+ const role = member.channel_role || CHANNEL_ROLES.MEMBER;
209
+ const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
168
210
 
169
211
  const canRemove = Boolean(
170
- (currentUserRole === 'owner' || currentUserRole === 'moder') &&
171
212
  isTargetRemovable &&
172
213
  member.user_id !== currentUserId
173
214
  );
174
215
 
175
216
  const canBan = Boolean(
176
- (currentUserRole === 'owner' || currentUserRole === 'moder') &&
177
- isTargetRemovable &&
178
- role !== 'pending' &&
217
+ canBanTargetMember(currentUserRole, role) &&
179
218
  member.user_id !== currentUserId &&
180
219
  !member.banned
181
220
  );
182
221
 
183
222
  const canUnban = Boolean(
184
- (currentUserRole === 'owner' || currentUserRole === 'moder') &&
185
- isTargetRemovable &&
186
- role !== 'pending' &&
223
+ canBanTargetMember(currentUserRole, role) &&
187
224
  member.user_id !== currentUserId &&
188
225
  member.banned
189
226
  );
190
227
 
191
- const canPromote = Boolean(
192
- currentUserRole === 'owner' &&
193
- role === 'member' &&
194
- member.user_id !== currentUserId
195
- );
228
+ const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
196
229
 
197
- const canDemote = Boolean(
198
- currentUserRole === 'owner' &&
199
- role === 'moder' &&
200
- member.user_id !== currentUserId
201
- );
230
+ const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
202
231
 
203
232
  items.push(
204
233
  <MemberItem
@@ -224,12 +253,12 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
224
253
  if (MediaItem === MediaGridItem) {
225
254
  // Default: use grid rows
226
255
  return mediaRows.map((row, rowIdx) => (
227
- <MediaRow key={row[0]?.id || rowIdx} row={row} onClick={handleOpenUrl} />
256
+ <MediaRow key={row[0]?.id || rowIdx} row={row} onClick={handleMediaClick} />
228
257
  ));
229
258
  }
230
259
  // Custom: render each item individually
231
260
  return mediaItems.map((item, idx) => (
232
- <MediaItem key={item.id || idx} item={item} onClick={handleOpenUrl} />
261
+ <MediaItem key={item.id || idx} item={item} onClick={handleMediaClick} />
233
262
  ));
234
263
  case 'links':
235
264
  return linkItems.map((item, idx) => (
@@ -242,7 +271,7 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
242
271
  default:
243
272
  return [];
244
273
  }
245
- }, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick, AvatarComponent, handleOpenUrl, MemberItem, MediaItem, LinkItem, FileItem]);
274
+ }, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick, AvatarComponent, handleMediaClick, handleOpenUrl, MemberItem, MediaItem, LinkItem, FileItem]);
246
275
 
247
276
  // Check if content is empty for the content tab (deferred)
248
277
  const isTabEmpty = vlistChildren.length === 0 && !(loading && contentTab !== 'members');
@@ -277,6 +306,16 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
277
306
  </VList>
278
307
  )}
279
308
  </div>
309
+
310
+ {/* Media Lightbox */}
311
+ {lightboxItems.length > 0 && (
312
+ <MediaLightbox
313
+ items={lightboxItems}
314
+ initialIndex={lightboxIndex}
315
+ isOpen={lightboxOpen}
316
+ onClose={closeLightbox}
317
+ />
318
+ )}
280
319
  </div>
281
320
  );
282
321
  });