@ermis-network/ermis-chat-react 1.0.9 → 2.0.1
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/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -1,321 +1,83 @@
|
|
|
1
|
-
import React
|
|
2
|
-
import { VList } from 'virtua';
|
|
3
|
-
|
|
4
|
-
import {
|
|
5
|
-
import { useBlockedState } from '../../hooks/useBlockedState';
|
|
6
|
-
import { MediaGridItem, MediaRow } from './MediaGridItem';
|
|
7
|
-
import { LinkListItem } from './LinkListItem';
|
|
8
|
-
import { FileListItem } from './FileListItem';
|
|
9
|
-
import { MemberListItem } from './MemberListItem';
|
|
10
|
-
import { TabEmptyState, TabLoadingState } from './States';
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { VList as _VList } from 'virtua';
|
|
3
|
+
const VList = _VList as any;
|
|
4
|
+
import { PENDING_STYLE, READY_STYLE } from './utils';
|
|
11
5
|
import { MediaLightbox } from '../MediaLightbox';
|
|
12
|
-
import type { ChannelInfoTabsProps,
|
|
13
|
-
import {
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
members,
|
|
25
|
-
AvatarComponent,
|
|
26
|
-
currentUserId,
|
|
27
|
-
currentUserRole,
|
|
28
|
-
onAddMemberClick,
|
|
29
|
-
onRemoveMember,
|
|
30
|
-
onBanMember,
|
|
31
|
-
onUnbanMember,
|
|
32
|
-
onPromoteMember,
|
|
33
|
-
onDemoteMember,
|
|
34
|
-
addMemberButtonLabel = 'Add Member',
|
|
35
|
-
AddMemberButtonComponent,
|
|
36
|
-
MemberItemComponent,
|
|
37
|
-
MediaItemComponent,
|
|
38
|
-
LinkItemComponent,
|
|
39
|
-
FileItemComponent,
|
|
40
|
-
EmptyStateComponent,
|
|
41
|
-
LoadingComponent,
|
|
6
|
+
import type { ChannelInfoTabsProps, ChannelInfoTabHeaderProps } from '../../types';
|
|
7
|
+
import { useChannelInfoTabs } from './useChannelInfoTabs';
|
|
8
|
+
|
|
9
|
+
/* =============================================
|
|
10
|
+
Component: DefaultChannelInfoTabHeader
|
|
11
|
+
Renders the tab buttons row.
|
|
12
|
+
============================================= */
|
|
13
|
+
|
|
14
|
+
export const DefaultChannelInfoTabHeader: React.FC<ChannelInfoTabHeaderProps> = React.memo(({
|
|
15
|
+
activeTab,
|
|
16
|
+
onTabChange,
|
|
17
|
+
availableTabs,
|
|
42
18
|
}) => {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
|
|
58
|
-
const contentTab = useDeferredValue(activeTab);
|
|
59
|
-
const isPending = activeTab !== contentTab;
|
|
60
|
-
|
|
61
|
-
// Always reset to the first available tab when the user switches channels
|
|
62
|
-
useEffect(() => {
|
|
63
|
-
setActiveTab(availableTabs[0]);
|
|
64
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
65
|
-
}, [channel?.cid, availableTabs]);
|
|
66
|
-
|
|
67
|
-
// Resolve sub-components with defaults
|
|
68
|
-
const MemberItem = MemberItemComponent || MemberListItem;
|
|
69
|
-
const MediaItem = MediaItemComponent || MediaGridItem;
|
|
70
|
-
const LinkItem = LinkItemComponent || LinkListItem;
|
|
71
|
-
const FileItem = FileItemComponent || FileListItem;
|
|
72
|
-
const EmptyState = EmptyStateComponent || TabEmptyState;
|
|
73
|
-
const Loading = LoadingComponent || TabLoadingState;
|
|
74
|
-
|
|
75
|
-
const [allAttachments, setAllAttachments] = useState<AttachmentItem[]>([]);
|
|
76
|
-
const [loading, setLoading] = useState(true);
|
|
77
|
-
|
|
78
|
-
const sortedMembers = useMemo(() => {
|
|
79
|
-
return [...members].sort((a, b) => {
|
|
80
|
-
const aWeight = ROLE_WEIGHTS[a.channel_role || CHANNEL_ROLES.MEMBER] || 0;
|
|
81
|
-
const bWeight = ROLE_WEIGHTS[b.channel_role || CHANNEL_ROLES.MEMBER] || 0;
|
|
82
|
-
return bWeight - aWeight;
|
|
83
|
-
});
|
|
84
|
-
}, [members]);
|
|
85
|
-
|
|
86
|
-
// Categorize attachments by type
|
|
87
|
-
const mediaItems = useMemo(() =>
|
|
88
|
-
allAttachments.filter(a => a.attachment_type === 'image' || a.attachment_type === 'video'),
|
|
89
|
-
[allAttachments]
|
|
90
|
-
);
|
|
91
|
-
|
|
92
|
-
const linkItems = useMemo(() =>
|
|
93
|
-
allAttachments.filter(a => a.attachment_type === 'linkPreview'),
|
|
94
|
-
[allAttachments]
|
|
95
|
-
);
|
|
96
|
-
|
|
97
|
-
const fileItems = useMemo(() =>
|
|
98
|
-
allAttachments.filter(a => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
|
|
99
|
-
[allAttachments]
|
|
19
|
+
return (
|
|
20
|
+
<div className="ermis-channel-info__media-tabs">
|
|
21
|
+
{availableTabs.map(tab => (
|
|
22
|
+
<button
|
|
23
|
+
key={tab}
|
|
24
|
+
className={`ermis-channel-info__media-tab ${activeTab === tab ? 'ermis-channel-info__media-tab--active' : ''}`}
|
|
25
|
+
onClick={() => onTabChange(tab)}
|
|
26
|
+
>
|
|
27
|
+
<span className="ermis-channel-info__media-tab-label">
|
|
28
|
+
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
29
|
+
</span>
|
|
30
|
+
</button>
|
|
31
|
+
))}
|
|
32
|
+
</div>
|
|
100
33
|
);
|
|
34
|
+
});
|
|
35
|
+
DefaultChannelInfoTabHeader.displayName = 'DefaultChannelInfoTabHeader';
|
|
101
36
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
setAllAttachments([]);
|
|
108
|
-
setLoading(false);
|
|
109
|
-
return;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
const fetchMedia = async () => {
|
|
113
|
-
setLoading(true);
|
|
114
|
-
try {
|
|
115
|
-
const response: any = await channel.queryAttachmentMessages();
|
|
116
|
-
|
|
117
|
-
if (active) {
|
|
118
|
-
const items = response?.attachments || [];
|
|
119
|
-
setAllAttachments(items);
|
|
120
|
-
}
|
|
121
|
-
} catch (err) {
|
|
122
|
-
console.error("Failed to query media for channel info", err);
|
|
123
|
-
if (active) setAllAttachments([]);
|
|
124
|
-
} finally {
|
|
125
|
-
if (active) setLoading(false);
|
|
126
|
-
}
|
|
127
|
-
};
|
|
128
|
-
|
|
129
|
-
fetchMedia();
|
|
130
|
-
|
|
131
|
-
return () => { active = false; };
|
|
132
|
-
}, [channel, isBanned, isBlocked]);
|
|
133
|
-
|
|
134
|
-
const tabCounts = useMemo<Record<MediaTab, number>>(() => ({
|
|
135
|
-
members: members.length,
|
|
136
|
-
media: mediaItems.length,
|
|
137
|
-
links: linkItems.length,
|
|
138
|
-
files: fileItems.length,
|
|
139
|
-
}), [members.length, mediaItems.length, linkItems.length, fileItems.length]);
|
|
140
|
-
|
|
141
|
-
const handleOpenUrl = useCallback((url: string) => {
|
|
142
|
-
window.open(url, '_blank', 'noopener,noreferrer');
|
|
143
|
-
}, []);
|
|
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
|
-
|
|
170
|
-
// Group media into rows of 3 for grid layout inside VList
|
|
171
|
-
const mediaRows = useMemo(() => {
|
|
172
|
-
const rows: AttachmentItem[][] = [];
|
|
173
|
-
for (let i = 0; i < mediaItems.length; i += 3) {
|
|
174
|
-
rows.push(mediaItems.slice(i, i + 3));
|
|
175
|
-
}
|
|
176
|
-
return rows;
|
|
177
|
-
}, [mediaItems]);
|
|
178
|
-
|
|
179
|
-
// Build VList children based on contentTab (deferred)
|
|
180
|
-
const vlistChildren = useMemo(() => {
|
|
181
|
-
switch (contentTab) {
|
|
182
|
-
case 'members': {
|
|
183
|
-
const items: React.ReactNode[] = [];
|
|
184
|
-
if (onAddMemberClick) {
|
|
185
|
-
if (AddMemberButtonComponent) {
|
|
186
|
-
items.push(
|
|
187
|
-
<div key="__add-member__" className="ermis-channel-info__add-member-wrap">
|
|
188
|
-
<AddMemberButtonComponent onClick={onAddMemberClick} label={addMemberButtonLabel} />
|
|
189
|
-
</div>
|
|
190
|
-
);
|
|
191
|
-
} else {
|
|
192
|
-
items.push(
|
|
193
|
-
<div key="__add-member__" className="ermis-channel-info__add-member-wrap">
|
|
194
|
-
<button className="ermis-channel-info__add-member-btn" onClick={onAddMemberClick}>
|
|
195
|
-
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
196
|
-
<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
|
|
197
|
-
<circle cx="8.5" cy="7" r="4"></circle>
|
|
198
|
-
<line x1="20" y1="8" x2="20" y2="14"></line>
|
|
199
|
-
<line x1="23" y1="11" x2="17" y2="11"></line>
|
|
200
|
-
</svg>
|
|
201
|
-
{addMemberButtonLabel}
|
|
202
|
-
</button>
|
|
203
|
-
</div>
|
|
204
|
-
);
|
|
205
|
-
}
|
|
206
|
-
}
|
|
207
|
-
sortedMembers.forEach(member => {
|
|
208
|
-
const role = member.channel_role || CHANNEL_ROLES.MEMBER;
|
|
209
|
-
const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
|
|
210
|
-
|
|
211
|
-
const canRemove = Boolean(
|
|
212
|
-
isTargetRemovable &&
|
|
213
|
-
member.user_id !== currentUserId
|
|
214
|
-
);
|
|
215
|
-
|
|
216
|
-
const canBan = Boolean(
|
|
217
|
-
canBanTargetMember(currentUserRole, role) &&
|
|
218
|
-
member.user_id !== currentUserId &&
|
|
219
|
-
!member.banned
|
|
220
|
-
);
|
|
221
|
-
|
|
222
|
-
const canUnban = Boolean(
|
|
223
|
-
canBanTargetMember(currentUserRole, role) &&
|
|
224
|
-
member.user_id !== currentUserId &&
|
|
225
|
-
member.banned
|
|
226
|
-
);
|
|
227
|
-
|
|
228
|
-
const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
|
|
229
|
-
|
|
230
|
-
const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
|
|
37
|
+
/* =============================================
|
|
38
|
+
Component: DefaultChannelInfoTabs
|
|
39
|
+
Self-contained tabs component with internal VList.
|
|
40
|
+
Kept for backward compatibility.
|
|
41
|
+
============================================= */
|
|
231
42
|
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
AvatarComponent={AvatarComponent}
|
|
237
|
-
onRemove={onRemoveMember}
|
|
238
|
-
canRemove={canRemove}
|
|
239
|
-
onBan={onBanMember}
|
|
240
|
-
canBan={canBan}
|
|
241
|
-
onUnban={onUnbanMember}
|
|
242
|
-
canUnban={canUnban}
|
|
243
|
-
onPromote={onPromoteMember}
|
|
244
|
-
canPromote={canPromote}
|
|
245
|
-
onDemote={onDemoteMember}
|
|
246
|
-
canDemote={canDemote}
|
|
247
|
-
/>
|
|
248
|
-
);
|
|
249
|
-
});
|
|
250
|
-
return items;
|
|
251
|
-
}
|
|
252
|
-
case 'media':
|
|
253
|
-
if (MediaItem === MediaGridItem) {
|
|
254
|
-
// Default: use grid rows
|
|
255
|
-
return mediaRows.map((row, rowIdx) => (
|
|
256
|
-
<MediaRow key={row[0]?.id || rowIdx} row={row} onClick={handleMediaClick} />
|
|
257
|
-
));
|
|
258
|
-
}
|
|
259
|
-
// Custom: render each item individually
|
|
260
|
-
return mediaItems.map((item, idx) => (
|
|
261
|
-
<MediaItem key={item.id || idx} item={item} onClick={handleMediaClick} />
|
|
262
|
-
));
|
|
263
|
-
case 'links':
|
|
264
|
-
return linkItems.map((item, idx) => (
|
|
265
|
-
<LinkItem key={item.id || idx} item={item} />
|
|
266
|
-
));
|
|
267
|
-
case 'files':
|
|
268
|
-
return fileItems.map((item, idx) => (
|
|
269
|
-
<FileItem key={item.id || idx} item={item} onClick={handleOpenUrl} />
|
|
270
|
-
));
|
|
271
|
-
default:
|
|
272
|
-
return [];
|
|
273
|
-
}
|
|
274
|
-
}, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick, AvatarComponent, handleMediaClick, handleOpenUrl, MemberItem, MediaItem, LinkItem, FileItem]);
|
|
43
|
+
export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo((props) => {
|
|
44
|
+
const {
|
|
45
|
+
TabHeaderComponent,
|
|
46
|
+
} = props;
|
|
275
47
|
|
|
276
|
-
|
|
277
|
-
const
|
|
278
|
-
const emptyLabel = contentTab === 'members' ? 'members' : contentTab;
|
|
48
|
+
const tabs = useChannelInfoTabs(props);
|
|
49
|
+
const TabHeader = TabHeaderComponent || DefaultChannelInfoTabHeader;
|
|
279
50
|
|
|
280
51
|
return (
|
|
281
52
|
<div className="ermis-channel-info__section ermis-channel-info__media-section">
|
|
282
|
-
<
|
|
283
|
-
{
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
>
|
|
289
|
-
<span className="ermis-channel-info__media-tab-label">
|
|
290
|
-
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
|
291
|
-
</span>
|
|
292
|
-
{tabCounts[tab] > 0 && (
|
|
293
|
-
<span className="ermis-channel-info__media-tab-count">{tabCounts[tab]}</span>
|
|
294
|
-
)}
|
|
295
|
-
</button>
|
|
296
|
-
))}
|
|
297
|
-
</div>
|
|
53
|
+
<TabHeader
|
|
54
|
+
activeTab={tabs.activeTab}
|
|
55
|
+
onTabChange={tabs.handleTabChange}
|
|
56
|
+
availableTabs={tabs.availableTabs}
|
|
57
|
+
tabCounts={{} as any}
|
|
58
|
+
/>
|
|
298
59
|
|
|
299
60
|
<div
|
|
300
61
|
className="ermis-channel-info__media-content"
|
|
301
|
-
style={isPending ? PENDING_STYLE : READY_STYLE}
|
|
62
|
+
style={tabs.isPending ? PENDING_STYLE : READY_STYLE}
|
|
302
63
|
>
|
|
303
|
-
{loading && contentTab !== 'members' ? <Loading /> : isTabEmpty ? <EmptyState label={emptyLabel} /> : (
|
|
304
|
-
<VList
|
|
305
|
-
{
|
|
64
|
+
{tabs.isPending || (tabs.loading && tabs.contentTab !== 'members') ? <tabs.Loading /> : tabs.isTabEmpty ? <tabs.EmptyState label={tabs.emptyLabel} /> : (
|
|
65
|
+
<VList data={tabs.vlistData}>
|
|
66
|
+
{tabs.renderVlistItem}
|
|
306
67
|
</VList>
|
|
307
68
|
)}
|
|
308
69
|
</div>
|
|
309
70
|
|
|
310
71
|
{/* Media Lightbox */}
|
|
311
|
-
{lightboxItems.length > 0 && (
|
|
72
|
+
{tabs.lightboxItems.length > 0 && (
|
|
312
73
|
<MediaLightbox
|
|
313
|
-
items={lightboxItems}
|
|
314
|
-
initialIndex={lightboxIndex}
|
|
315
|
-
isOpen={lightboxOpen}
|
|
316
|
-
onClose={closeLightbox}
|
|
74
|
+
items={tabs.lightboxItems}
|
|
75
|
+
initialIndex={tabs.lightboxIndex}
|
|
76
|
+
isOpen={tabs.lightboxOpen}
|
|
77
|
+
onClose={tabs.closeLightbox}
|
|
317
78
|
/>
|
|
318
79
|
)}
|
|
319
80
|
</div>
|
|
320
81
|
);
|
|
321
82
|
});
|
|
83
|
+
DefaultChannelInfoTabs.displayName = 'DefaultChannelInfoTabs';
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import React, { useState, useEffect } from 'react';
|
|
2
|
-
import { Panel } from '../Panel';
|
|
2
|
+
import { Panel as DefaultPanel } from '../Panel';
|
|
3
|
+
import { useChatComponents } from '../../context/ChatComponentsContext';
|
|
3
4
|
import { useChatClient } from '../../hooks/useChatClient';
|
|
4
5
|
import type { ChannelSettingsPanelProps } from '../../types';
|
|
5
6
|
import { isGroupChannel } from '../../channelTypeUtils';
|
|
6
7
|
import { CHANNEL_ROLES } from '../../channelRoleUtils';
|
|
7
8
|
|
|
9
|
+
import { useChannelSettings } from './useChannelSettings';
|
|
10
|
+
|
|
8
11
|
export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.memo(({
|
|
9
12
|
isOpen,
|
|
10
13
|
onClose,
|
|
@@ -21,115 +24,38 @@ export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.m
|
|
|
21
24
|
],
|
|
22
25
|
workspaceTopicsTitle = 'Workspace Topics',
|
|
23
26
|
topicsFeatureName = 'Topics',
|
|
24
|
-
topicsFeatureDescription = '
|
|
27
|
+
topicsFeatureDescription = 'Allow users to reply to messages with dedicated conversation threads. Disabling this hides the reply-in-topic button for everyone.',
|
|
25
28
|
}) => {
|
|
26
|
-
// Config state
|
|
27
29
|
const { client } = useChatClient();
|
|
30
|
+
const { PanelComponent } = useChatComponents();
|
|
31
|
+
const Panel = PanelComponent || DefaultPanel;
|
|
28
32
|
const currentUserId = client?.userID;
|
|
29
33
|
const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
|
|
30
|
-
const isOwner = currentUserRole === CHANNEL_ROLES.OWNER;
|
|
31
34
|
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
35
|
+
const {
|
|
36
|
+
slowMode,
|
|
37
|
+
setSlowMode,
|
|
38
|
+
topicsEnabled,
|
|
39
|
+
setTopicsEnabled,
|
|
40
|
+
capabilities,
|
|
41
|
+
toggleCapability,
|
|
42
|
+
keywords,
|
|
43
|
+
newKeyword,
|
|
44
|
+
setNewKeyword,
|
|
45
|
+
handleAddNewKeyword,
|
|
46
|
+
handleRemoveKeyword,
|
|
47
|
+
isSaving,
|
|
48
|
+
error,
|
|
49
|
+
isDirty,
|
|
50
|
+
isOwner,
|
|
51
|
+
handleSave,
|
|
52
|
+
} = useChannelSettings({
|
|
53
|
+
channel: channel as any,
|
|
54
|
+
isOpen,
|
|
55
|
+
onClose,
|
|
56
|
+
currentUserRole,
|
|
43
57
|
});
|
|
44
58
|
|
|
45
|
-
const [keywords, setKeywords] = useState<string[]>([]);
|
|
46
|
-
const [newKeyword, setNewKeyword] = useState('');
|
|
47
|
-
const [isSaving, setIsSaving] = useState(false);
|
|
48
|
-
const [error, setError] = useState<string | null>(null);
|
|
49
|
-
|
|
50
|
-
// Sync state when panel opens or channel updates
|
|
51
|
-
useEffect(() => {
|
|
52
|
-
if (!channel) return;
|
|
53
|
-
|
|
54
|
-
const syncData = (dataToSync = channel.data) => {
|
|
55
|
-
console.log('---syncData---', dataToSync);
|
|
56
|
-
setSlowMode((dataToSync?.member_message_cooldown as number) || 0);
|
|
57
|
-
setKeywords((dataToSync?.filter_words as string[]) || []);
|
|
58
|
-
setTopicsEnabled(dataToSync?.topics_enabled === true);
|
|
59
|
-
|
|
60
|
-
const caps = dataToSync?.member_capabilities as string[] || [];
|
|
61
|
-
setCapabilities({
|
|
62
|
-
'send-message': caps.includes('send-message'),
|
|
63
|
-
'send-links': caps.includes('send-links'),
|
|
64
|
-
'update-own-message': caps.includes('update-own-message'),
|
|
65
|
-
'delete-own-message': caps.includes('delete-own-message'),
|
|
66
|
-
'send-reaction': caps.includes('send-reaction'),
|
|
67
|
-
'pin-message': caps.includes('pin-message'),
|
|
68
|
-
'create-poll': caps.includes('create-poll'),
|
|
69
|
-
'vote-poll': caps.includes('vote-poll'),
|
|
70
|
-
});
|
|
71
|
-
setError(null);
|
|
72
|
-
};
|
|
73
|
-
|
|
74
|
-
if (isOpen) {
|
|
75
|
-
syncData();
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// Listen to real-time changes
|
|
79
|
-
const subscription = channel.on('channel.updated', (event: any) => {
|
|
80
|
-
const latestData = event?.channel || channel.data;
|
|
81
|
-
// Force mutating local channel.data to ensure future syncData hits cache
|
|
82
|
-
if (event?.channel && channel.data) {
|
|
83
|
-
Object.assign(channel.data, event.channel);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
if (isOpen) {
|
|
87
|
-
syncData(latestData);
|
|
88
|
-
}
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
return () => {
|
|
92
|
-
subscription?.unsubscribe();
|
|
93
|
-
};
|
|
94
|
-
}, [isOpen, channel]);
|
|
95
|
-
|
|
96
|
-
const toggleCapability = (key: string) => {
|
|
97
|
-
setCapabilities(prev => ({ ...prev, [key]: !prev[key] }));
|
|
98
|
-
};
|
|
99
|
-
|
|
100
|
-
// Compute dirty state
|
|
101
|
-
const isSlowModeChanged = slowMode !== ((channel?.data?.member_message_cooldown as number) || 0);
|
|
102
|
-
const isTopicsChanged = topicsEnabled !== (channel?.data?.topics_enabled === true);
|
|
103
|
-
|
|
104
|
-
const currentKeywordsSorted = [...keywords].sort().join(',');
|
|
105
|
-
const originalKeywordsSorted = [...((channel?.data?.filter_words as string[]) || [])].sort().join(',');
|
|
106
|
-
const isKeywordsChanged = currentKeywordsSorted !== originalKeywordsSorted;
|
|
107
|
-
|
|
108
|
-
const originalCapabilities = channel?.data?.member_capabilities as string[] || [];
|
|
109
|
-
const initialCapabilities: Record<string, boolean> = {
|
|
110
|
-
'send-message': originalCapabilities.includes('send-message'),
|
|
111
|
-
'send-links': originalCapabilities.includes('send-links'),
|
|
112
|
-
'update-own-message': originalCapabilities.includes('update-own-message'),
|
|
113
|
-
'delete-own-message': originalCapabilities.includes('delete-own-message'),
|
|
114
|
-
'send-reaction': originalCapabilities.includes('send-reaction'),
|
|
115
|
-
'pin-message': originalCapabilities.includes('pin-message'),
|
|
116
|
-
'create-poll': originalCapabilities.includes('create-poll'),
|
|
117
|
-
'vote-poll': originalCapabilities.includes('vote-poll'),
|
|
118
|
-
};
|
|
119
|
-
const isCapabilitiesChanged = Object.keys(capabilities).some(k => capabilities[k] !== initialCapabilities[k]);
|
|
120
|
-
|
|
121
|
-
const isDirty = isSlowModeChanged || isKeywordsChanged || isCapabilitiesChanged || isTopicsChanged;
|
|
122
|
-
|
|
123
|
-
const handleAddNewKeyword = () => {
|
|
124
|
-
if (newKeyword.trim()) {
|
|
125
|
-
const keyword = newKeyword.trim().toLowerCase();
|
|
126
|
-
if (!keywords.includes(keyword)) {
|
|
127
|
-
setKeywords(prev => [...prev, keyword]);
|
|
128
|
-
}
|
|
129
|
-
setNewKeyword('');
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
|
|
133
59
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
134
60
|
if (e.key === 'Enter') {
|
|
135
61
|
e.preventDefault();
|
|
@@ -137,76 +63,6 @@ export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.m
|
|
|
137
63
|
}
|
|
138
64
|
};
|
|
139
65
|
|
|
140
|
-
const handeRemoveKeyword = (kw: string) => {
|
|
141
|
-
setKeywords(prev => prev.filter(k => k !== kw));
|
|
142
|
-
};
|
|
143
|
-
|
|
144
|
-
const handleSave = async () => {
|
|
145
|
-
if (!channel) return;
|
|
146
|
-
setIsSaving(true);
|
|
147
|
-
setError(null);
|
|
148
|
-
try {
|
|
149
|
-
const dataUpdates: any = {};
|
|
150
|
-
let capabilitiesArray: string[] | null = null;
|
|
151
|
-
|
|
152
|
-
if (isSlowModeChanged) {
|
|
153
|
-
dataUpdates.member_message_cooldown = slowMode;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
if (isKeywordsChanged) {
|
|
157
|
-
dataUpdates.filter_words = keywords;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
if (isCapabilitiesChanged) {
|
|
161
|
-
const controlledKeys = Object.keys(capabilities);
|
|
162
|
-
const originalCaps = (channel.data?.member_capabilities as string[]) || [];
|
|
163
|
-
|
|
164
|
-
// Preserve unmanaged original capabilities (e.g. create-call, join-call)
|
|
165
|
-
const unmanagedCaps = originalCaps.filter(c => !controlledKeys.includes(c));
|
|
166
|
-
|
|
167
|
-
// Extract managed capabilities that are currently enabled (true)
|
|
168
|
-
const managedEnabledCaps = controlledKeys.filter(k => capabilities[k as keyof typeof capabilities]);
|
|
169
|
-
|
|
170
|
-
// Merge into the final payload array
|
|
171
|
-
capabilitiesArray = [...unmanagedCaps, ...managedEnabledCaps];
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
if (Object.keys(dataUpdates).length > 0 || capabilitiesArray !== null) {
|
|
175
|
-
const payload: any = {};
|
|
176
|
-
|
|
177
|
-
if (Object.keys(dataUpdates).length > 0) {
|
|
178
|
-
payload.data = dataUpdates;
|
|
179
|
-
if (channel.data) Object.assign(channel.data, dataUpdates);
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
if (capabilitiesArray !== null) {
|
|
183
|
-
payload.capabilities = capabilitiesArray;
|
|
184
|
-
if (channel.data) {
|
|
185
|
-
// Local fallback naming to keep UI synchronous
|
|
186
|
-
channel.data.member_capabilities = capabilitiesArray;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
// Use _update instead of update to safely construct root-level payloads
|
|
191
|
-
await (channel as any)._update(payload);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
if (isTopicsChanged) {
|
|
195
|
-
if (topicsEnabled) {
|
|
196
|
-
await channel.enableTopics();
|
|
197
|
-
} else {
|
|
198
|
-
await channel.disableTopics();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
onClose();
|
|
203
|
-
} catch (err: any) {
|
|
204
|
-
setError(err?.message || 'Failed to update settings');
|
|
205
|
-
} finally {
|
|
206
|
-
setIsSaving(false);
|
|
207
|
-
}
|
|
208
|
-
};
|
|
209
|
-
|
|
210
66
|
// We do NOT return null based on !isOpen so the sliding CSS transition is preserved.
|
|
211
67
|
return (
|
|
212
68
|
<Panel isOpen={isOpen} onClose={onClose} title={title} className="ermis-settings-panel">
|
|
@@ -415,7 +271,7 @@ export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.m
|
|
|
415
271
|
>
|
|
416
272
|
{kw}
|
|
417
273
|
<button
|
|
418
|
-
onClick={() =>
|
|
274
|
+
onClick={() => handleRemoveKeyword(kw)}
|
|
419
275
|
style={{
|
|
420
276
|
display: 'flex',
|
|
421
277
|
alignItems: 'center',
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
-
import { Modal } from '../Modal';
|
|
2
|
+
import { Modal as DefaultModal } from '../Modal';
|
|
3
|
+
import { useChatComponents } from '../../context/ChatComponentsContext';
|
|
3
4
|
import type { EditChannelModalProps, EditChannelData } from '../../types';
|
|
4
5
|
import { isGroupChannel } from '../../channelTypeUtils';
|
|
5
6
|
|
|
@@ -30,6 +31,8 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
30
31
|
const originalDescription = (channel.data?.description as string) || '';
|
|
31
32
|
const originalPublic = Boolean(channel.data?.public);
|
|
32
33
|
const isTeamOrMeetingChannel = isGroupChannel(channel);
|
|
34
|
+
const { ModalComponent } = useChatComponents();
|
|
35
|
+
const Modal = ModalComponent || DefaultModal;
|
|
33
36
|
|
|
34
37
|
// Form state
|
|
35
38
|
const [name, setName] = useState(originalName);
|
|
@@ -119,7 +122,7 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
119
122
|
// If consumer provides custom save handler, delegate entirely
|
|
120
123
|
if (onSave) {
|
|
121
124
|
if (selectedFile) {
|
|
122
|
-
const response = await channel.
|
|
125
|
+
const response = await channel.uploadFilePresigned(selectedFile, selectedFile.name, selectedFile.type);
|
|
123
126
|
(payload || {} as EditChannelData).image = response.file;
|
|
124
127
|
}
|
|
125
128
|
await onSave(payload || {});
|
|
@@ -132,7 +135,7 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
132
135
|
|
|
133
136
|
// Upload image if changed
|
|
134
137
|
if (selectedFile) {
|
|
135
|
-
const response = await channel.
|
|
138
|
+
const response = await channel.uploadFilePresigned(selectedFile, selectedFile.name, selectedFile.type);
|
|
136
139
|
finalPayload.image = response.file;
|
|
137
140
|
}
|
|
138
141
|
|