@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.
- package/dist/index.cjs +3802 -1772
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +836 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.mjs +3755 -1761
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +233 -0
- package/src/components/ChannelHeader.tsx +126 -5
- package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +514 -47
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +21 -3
- package/src/components/MessageItem.tsx +10 -12
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +174 -54
- package/src/components/PendingOverlay.tsx +51 -0
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +162 -47
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -8
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +28 -5
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +9 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +67 -10
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +276 -6
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +170 -13
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +76 -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 +2 -0
- 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
|
|
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
|
-
|
|
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-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
|
385
|
+
const canEditChannel = (isTeamChannel || isTopic) && !isBanned && canManageChannel(currentUserRole);
|
|
306
386
|
|
|
307
387
|
const handleEditChannelClick = useCallback(() => {
|
|
308
|
-
|
|
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={
|
|
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
|
-
<
|
|
338
|
-
<
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
|
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
|
|
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[] =
|
|
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 ||
|
|
64
|
-
const bWeight = ROLE_WEIGHTS[b.channel_role ||
|
|
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 ||
|
|
167
|
-
const isTargetRemovable =
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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={
|
|
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={
|
|
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
|
});
|