@ermis-network/ermis-chat-react 1.0.7 → 1.0.9
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 +2787 -1858
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +364 -8
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +160 -1
- package/dist/index.d.ts +160 -1
- package/dist/index.mjs +2787 -1890
- 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/ChannelActions.tsx +13 -11
- package/src/components/ChannelHeader.tsx +89 -4
- package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +59 -14
- 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 +14 -11
- package/src/components/MessageItem.tsx +2 -1
- package/src/components/MessageRenderers.tsx +168 -46
- package/src/components/PendingOverlay.tsx +11 -1
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +91 -7
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -12
- package/src/hooks/useChannelListUpdates.ts +6 -4
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +3 -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 +61 -9
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-list.css +59 -0
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +99 -8
- package/src/styles/_message-list.css +25 -0
- package/src/styles/index.css +1 -0
- package/src/types.ts +46 -0
|
@@ -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,7 +40,7 @@ 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);
|
|
35
44
|
const isTopic = Boolean(channel?.data?.parent_cid);
|
|
36
45
|
|
|
37
46
|
const { isBanned } = useBannedState(channel, currentUserId);
|
|
@@ -68,8 +77,8 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
68
77
|
|
|
69
78
|
const sortedMembers = useMemo(() => {
|
|
70
79
|
return [...members].sort((a, b) => {
|
|
71
|
-
const aWeight = ROLE_WEIGHTS[a.channel_role ||
|
|
72
|
-
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;
|
|
73
82
|
return bWeight - aWeight;
|
|
74
83
|
});
|
|
75
84
|
}, [members]);
|
|
@@ -133,6 +142,31 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
133
142
|
window.open(url, '_blank', 'noopener,noreferrer');
|
|
134
143
|
}, []);
|
|
135
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
|
+
|
|
136
170
|
// Group media into rows of 3 for grid layout inside VList
|
|
137
171
|
const mediaRows = useMemo(() => {
|
|
138
172
|
const rows: AttachmentItem[][] = [];
|
|
@@ -171,42 +205,29 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
171
205
|
}
|
|
172
206
|
}
|
|
173
207
|
sortedMembers.forEach(member => {
|
|
174
|
-
const role = member.channel_role ||
|
|
175
|
-
const isTargetRemovable =
|
|
208
|
+
const role = member.channel_role || CHANNEL_ROLES.MEMBER;
|
|
209
|
+
const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
|
|
176
210
|
|
|
177
211
|
const canRemove = Boolean(
|
|
178
|
-
(currentUserRole === 'owner' || currentUserRole === 'moder') &&
|
|
179
212
|
isTargetRemovable &&
|
|
180
213
|
member.user_id !== currentUserId
|
|
181
214
|
);
|
|
182
215
|
|
|
183
216
|
const canBan = Boolean(
|
|
184
|
-
(currentUserRole
|
|
185
|
-
isTargetRemovable &&
|
|
186
|
-
role !== 'pending' &&
|
|
217
|
+
canBanTargetMember(currentUserRole, role) &&
|
|
187
218
|
member.user_id !== currentUserId &&
|
|
188
219
|
!member.banned
|
|
189
220
|
);
|
|
190
221
|
|
|
191
222
|
const canUnban = Boolean(
|
|
192
|
-
(currentUserRole
|
|
193
|
-
isTargetRemovable &&
|
|
194
|
-
role !== 'pending' &&
|
|
223
|
+
canBanTargetMember(currentUserRole, role) &&
|
|
195
224
|
member.user_id !== currentUserId &&
|
|
196
225
|
member.banned
|
|
197
226
|
);
|
|
198
227
|
|
|
199
|
-
const canPromote =
|
|
200
|
-
currentUserRole === 'owner' &&
|
|
201
|
-
role === 'member' &&
|
|
202
|
-
member.user_id !== currentUserId
|
|
203
|
-
);
|
|
228
|
+
const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
|
|
204
229
|
|
|
205
|
-
const canDemote =
|
|
206
|
-
currentUserRole === 'owner' &&
|
|
207
|
-
role === 'moder' &&
|
|
208
|
-
member.user_id !== currentUserId
|
|
209
|
-
);
|
|
230
|
+
const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
|
|
210
231
|
|
|
211
232
|
items.push(
|
|
212
233
|
<MemberItem
|
|
@@ -232,12 +253,12 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
232
253
|
if (MediaItem === MediaGridItem) {
|
|
233
254
|
// Default: use grid rows
|
|
234
255
|
return mediaRows.map((row, rowIdx) => (
|
|
235
|
-
<MediaRow key={row[0]?.id || rowIdx} row={row} onClick={
|
|
256
|
+
<MediaRow key={row[0]?.id || rowIdx} row={row} onClick={handleMediaClick} />
|
|
236
257
|
));
|
|
237
258
|
}
|
|
238
259
|
// Custom: render each item individually
|
|
239
260
|
return mediaItems.map((item, idx) => (
|
|
240
|
-
<MediaItem key={item.id || idx} item={item} onClick={
|
|
261
|
+
<MediaItem key={item.id || idx} item={item} onClick={handleMediaClick} />
|
|
241
262
|
));
|
|
242
263
|
case 'links':
|
|
243
264
|
return linkItems.map((item, idx) => (
|
|
@@ -250,7 +271,7 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
250
271
|
default:
|
|
251
272
|
return [];
|
|
252
273
|
}
|
|
253
|
-
}, [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]);
|
|
254
275
|
|
|
255
276
|
// Check if content is empty for the content tab (deferred)
|
|
256
277
|
const isTabEmpty = vlistChildren.length === 0 && !(loading && contentTab !== 'members');
|
|
@@ -285,6 +306,16 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
|
|
|
285
306
|
</VList>
|
|
286
307
|
)}
|
|
287
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
|
+
)}
|
|
288
319
|
</div>
|
|
289
320
|
);
|
|
290
321
|
});
|
|
@@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|
|
2
2
|
import { Panel } from '../Panel';
|
|
3
3
|
import { useChatClient } from '../../hooks/useChatClient';
|
|
4
4
|
import type { ChannelSettingsPanelProps } from '../../types';
|
|
5
|
+
import { isGroupChannel } from '../../channelTypeUtils';
|
|
6
|
+
import { CHANNEL_ROLES } from '../../channelRoleUtils';
|
|
5
7
|
|
|
6
8
|
export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.memo(({
|
|
7
9
|
isOpen,
|
|
@@ -25,7 +27,7 @@ export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.m
|
|
|
25
27
|
const { client } = useChatClient();
|
|
26
28
|
const currentUserId = client?.userID;
|
|
27
29
|
const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
|
|
28
|
-
const isOwner = currentUserRole ===
|
|
30
|
+
const isOwner = currentUserRole === CHANNEL_ROLES.OWNER;
|
|
29
31
|
|
|
30
32
|
const [slowMode, setSlowMode] = useState<number>(0);
|
|
31
33
|
const [topicsEnabled, setTopicsEnabled] = useState<boolean>(false);
|
|
@@ -444,7 +446,7 @@ export const ChannelSettingsPanel: React.FC<ChannelSettingsPanelProps> = React.m
|
|
|
444
446
|
</section>
|
|
445
447
|
|
|
446
448
|
{/* Section 3: Features */}
|
|
447
|
-
{(channel
|
|
449
|
+
{isGroupChannel(channel) && (
|
|
448
450
|
<section
|
|
449
451
|
className="ermis-settings-panel__section"
|
|
450
452
|
style={{
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
2
|
import { Modal } from '../Modal';
|
|
3
3
|
import type { EditChannelModalProps, EditChannelData } from '../../types';
|
|
4
|
+
import { isGroupChannel } from '../../channelTypeUtils';
|
|
4
5
|
|
|
5
6
|
const DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
6
7
|
|
|
@@ -28,7 +29,7 @@ export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
|
28
29
|
const originalImage = (channel.data?.image as string) || '';
|
|
29
30
|
const originalDescription = (channel.data?.description as string) || '';
|
|
30
31
|
const originalPublic = Boolean(channel.data?.public);
|
|
31
|
-
const isTeamOrMeetingChannel = channel
|
|
32
|
+
const isTeamOrMeetingChannel = isGroupChannel(channel);
|
|
32
33
|
|
|
33
34
|
// Form state
|
|
34
35
|
const [name, setName] = useState(originalName);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState } from 'react';
|
|
2
2
|
import { Dropdown } from '../Dropdown';
|
|
3
3
|
import type { ChannelInfoMemberItemProps } from '../../types';
|
|
4
|
+
import { CHANNEL_ROLES } from '../../channelRoleUtils';
|
|
4
5
|
|
|
5
6
|
export const MemberListItem = React.memo(({
|
|
6
7
|
member, AvatarComponent,
|
|
@@ -14,7 +15,7 @@ export const MemberListItem = React.memo(({
|
|
|
14
15
|
const isOpen = anchorRect !== null;
|
|
15
16
|
|
|
16
17
|
if (!member) return null;
|
|
17
|
-
const role = member.channel_role ||
|
|
18
|
+
const role = member.channel_role || CHANNEL_ROLES.MEMBER;
|
|
18
19
|
const hasActions = canRemove || canBan || canUnban || canPromote || canDemote;
|
|
19
20
|
|
|
20
21
|
return (
|
|
@@ -4,6 +4,7 @@ import type { Channel, Event, ChannelFilters } from '@ermis-network/ermis-chat-s
|
|
|
4
4
|
import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import { useChannelListUpdates } from '../hooks/useChannelListUpdates';
|
|
7
|
+
import { useOnlineUsers } from '../hooks/useOnlineUsers';
|
|
7
8
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
8
9
|
import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
|
|
9
10
|
import { usePendingState } from '../hooks/usePendingState';
|
|
@@ -14,6 +15,8 @@ export type { ChannelListProps, ChannelItemProps } from '../types';
|
|
|
14
15
|
import type { ChannelActionsProps } from '../types';
|
|
15
16
|
import { TopicModal } from './TopicModal';
|
|
16
17
|
import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
|
|
18
|
+
import { isDirectChannel, hasTopicsEnabled } from '../channelTypeUtils';
|
|
19
|
+
import { canManageChannel, isPendingMember, isSkippedMember, isFriendChannel } from '../channelRoleUtils';
|
|
17
20
|
|
|
18
21
|
export { DefaultChannelActions } from './ChannelActions';
|
|
19
22
|
export type { ChannelAction, ChannelActionsProps } from '../types';
|
|
@@ -117,6 +120,7 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
|
117
120
|
hiddenActions,
|
|
118
121
|
actionLabels,
|
|
119
122
|
actionIcons,
|
|
123
|
+
isOnline,
|
|
120
124
|
}) => {
|
|
121
125
|
const { client } = useChatClient();
|
|
122
126
|
const currentUserId = client.userID;
|
|
@@ -175,7 +179,12 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
|
|
|
175
179
|
|
|
176
180
|
return (
|
|
177
181
|
<div className={itemClass} onClick={handleClick}>
|
|
178
|
-
<
|
|
182
|
+
<div className="ermis-channel-list__item-avatar-wrapper">
|
|
183
|
+
<AvatarComponent image={image} name={name} size={40} disableLightbox />
|
|
184
|
+
{isOnline !== undefined && (
|
|
185
|
+
<span className={`ermis-channel-list__online-dot ermis-channel-list__online-dot--${isOnline ? 'online' : 'offline'}`} />
|
|
186
|
+
)}
|
|
187
|
+
</div>
|
|
179
188
|
<div className="ermis-channel-list__item-content">
|
|
180
189
|
<div className="ermis-channel-list__item-top-row">
|
|
181
190
|
<div className="ermis-channel-list__item-name">{name}</div>
|
|
@@ -281,6 +290,7 @@ type ChannelRowProps = {
|
|
|
281
290
|
hiddenActions?: string[];
|
|
282
291
|
actionLabels?: import('../types').ChannelActionLabels;
|
|
283
292
|
actionIcons?: import('../types').ChannelActionIcons;
|
|
293
|
+
isOnline?: boolean;
|
|
284
294
|
};
|
|
285
295
|
|
|
286
296
|
const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
@@ -302,10 +312,12 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
302
312
|
hiddenActions,
|
|
303
313
|
actionLabels,
|
|
304
314
|
actionIcons,
|
|
315
|
+
isOnline,
|
|
305
316
|
}) => {
|
|
306
317
|
// Use the new custom hook to handle all row-level realtime updates
|
|
307
318
|
const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
|
|
308
319
|
const { isPending } = usePendingState(channel, currentUserId);
|
|
320
|
+
const isSkipped = isSkippedMember(channel.state?.membership?.channel_role as string);
|
|
309
321
|
|
|
310
322
|
const channelState = channel.state as unknown as Record<string, unknown> | undefined;
|
|
311
323
|
const rawUnreadCount = (channelState?.unreadCount as number) ?? 0;
|
|
@@ -313,7 +325,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
313
325
|
const isClosedTopic = channel.data?.is_closed_topic === true;
|
|
314
326
|
|
|
315
327
|
// Render logic continues...
|
|
316
|
-
const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending) ? 0 : rawUnreadCount;
|
|
328
|
+
const unreadCount = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? 0 : rawUnreadCount;
|
|
317
329
|
const hasUnread = unreadCount > 0;
|
|
318
330
|
|
|
319
331
|
// Derive last message preview computation
|
|
@@ -324,10 +336,10 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
324
336
|
[channel, channel.state?.latestMessages, updateCount]
|
|
325
337
|
);
|
|
326
338
|
|
|
327
|
-
// Hide last message preview when banned, blocked, or
|
|
328
|
-
const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageText;
|
|
329
|
-
const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending) ? '' : rawLastMessageUser;
|
|
330
|
-
const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending) ? null : rawLastMessageTimestamp;
|
|
339
|
+
// Hide last message preview when banned, blocked, pending or skipped
|
|
340
|
+
const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageText;
|
|
341
|
+
const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageUser;
|
|
342
|
+
const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? null : rawLastMessageTimestamp;
|
|
331
343
|
|
|
332
344
|
if (renderChannel) {
|
|
333
345
|
return (
|
|
@@ -362,6 +374,7 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
|
|
|
362
374
|
hiddenActions={hiddenActions}
|
|
363
375
|
actionLabels={actionLabels}
|
|
364
376
|
actionIcons={actionIcons}
|
|
377
|
+
isOnline={isOnline}
|
|
365
378
|
/>
|
|
366
379
|
);
|
|
367
380
|
});
|
|
@@ -412,7 +425,7 @@ export const ChannelTopicGroup = React.memo(({
|
|
|
412
425
|
const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
|
|
413
426
|
|
|
414
427
|
const userRole = channel.state?.members?.[currentUserId]?.channel_role;
|
|
415
|
-
const hasTopicAddPermission =
|
|
428
|
+
const hasTopicAddPermission = canManageChannel(userRole);
|
|
416
429
|
|
|
417
430
|
const getTopicTime = (t: Channel) => {
|
|
418
431
|
const lastMsg = t.state?.latestMessages?.slice(-1)[0];
|
|
@@ -429,7 +442,7 @@ export const ChannelTopicGroup = React.memo(({
|
|
|
429
442
|
const bPinned = b.data?.is_pinned === true;
|
|
430
443
|
if (aPinned && !bPinned) return -1;
|
|
431
444
|
if (!aPinned && bPinned) return 1;
|
|
432
|
-
|
|
445
|
+
|
|
433
446
|
return getTopicTime(b) - getTopicTime(a);
|
|
434
447
|
});
|
|
435
448
|
}, [channel.state?.topics, topicUpdateCount]);
|
|
@@ -477,7 +490,7 @@ export const ChannelTopicGroup = React.memo(({
|
|
|
477
490
|
className={`ermis-channel-list__topic-header ${isExpanded ? 'ermis-channel-list__topic-header--expanded' : ''}`}
|
|
478
491
|
onClick={handleToggle}
|
|
479
492
|
>
|
|
480
|
-
<AvatarComponent image={image} name={name} size={40} />
|
|
493
|
+
<AvatarComponent image={image} name={name} size={40} disableLightbox />
|
|
481
494
|
<div className="ermis-channel-list__topic-header-name">{name}</div>
|
|
482
495
|
|
|
483
496
|
{channel.data?.is_pinned === true && PinnedIconComponent && (
|
|
@@ -577,6 +590,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
577
590
|
hiddenActions,
|
|
578
591
|
actionLabels,
|
|
579
592
|
actionIcons,
|
|
593
|
+
showOnlineStatus = true,
|
|
580
594
|
}) => {
|
|
581
595
|
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
582
596
|
const [channels, setChannels] = useState<Channel[]>([]);
|
|
@@ -632,7 +646,13 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
632
646
|
|
|
633
647
|
channels.forEach(ch => {
|
|
634
648
|
const ms = ch.state?.membership as Record<string, unknown> | undefined;
|
|
635
|
-
const isPending = ms?.channel_role
|
|
649
|
+
const isPending = isPendingMember(ms?.channel_role as string);
|
|
650
|
+
const isSkipped = isSkippedMember(ms?.channel_role as string);
|
|
651
|
+
|
|
652
|
+
if (isSkipped) {
|
|
653
|
+
return; // Filter out completely
|
|
654
|
+
}
|
|
655
|
+
|
|
636
656
|
if (isPending) {
|
|
637
657
|
pending.push(ch);
|
|
638
658
|
} else if (ch.data?.is_pinned) {
|
|
@@ -666,6 +686,28 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
666
686
|
// Real-time: List manipulation (move to top, add, delete)
|
|
667
687
|
useChannelListUpdates(channels, setChannels);
|
|
668
688
|
|
|
689
|
+
// Online status: compute set of online friend user IDs (skip if disabled)
|
|
690
|
+
const onlineUsers = useOnlineUsers(showOnlineStatus ? channels : []);
|
|
691
|
+
|
|
692
|
+
// Helper: get the "other" user ID from a direct channel
|
|
693
|
+
const getOtherUserId = useCallback((channel: Channel): string | undefined => {
|
|
694
|
+
if (!isDirectChannel(channel) || !client.userID) return undefined;
|
|
695
|
+
const members = channel.state?.members;
|
|
696
|
+
if (!members) return undefined;
|
|
697
|
+
for (const memberId of Object.keys(members)) {
|
|
698
|
+
if (memberId !== client.userID) return memberId;
|
|
699
|
+
}
|
|
700
|
+
return undefined;
|
|
701
|
+
}, [client.userID]);
|
|
702
|
+
|
|
703
|
+
// Helper: compute isOnline for a channel (undefined for non-friend channels)
|
|
704
|
+
const getIsOnline = useCallback((channel: Channel): boolean | undefined => {
|
|
705
|
+
const otherUserId = getOtherUserId(channel);
|
|
706
|
+
if (!otherUserId || !client.userID) return undefined;
|
|
707
|
+
if (!isFriendChannel(channel, otherUserId, client.userID)) return undefined;
|
|
708
|
+
return onlineUsers.has(otherUserId);
|
|
709
|
+
}, [getOtherUserId, onlineUsers, client.userID]);
|
|
710
|
+
|
|
669
711
|
const handleSelect = useCallback(
|
|
670
712
|
(channel: Channel) => {
|
|
671
713
|
setActiveChannel(channel);
|
|
@@ -675,10 +717,11 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
675
717
|
const ms = channel.state?.membership as Record<string, unknown> | undefined;
|
|
676
718
|
const chState = channel.state as unknown as Record<string, unknown> | undefined;
|
|
677
719
|
const isBannedInChannel = Boolean(ms?.banned);
|
|
678
|
-
const isBlockedInChannel = channel
|
|
679
|
-
const isPending = ms?.channel_role
|
|
720
|
+
const isBlockedInChannel = isDirectChannel(channel) && Boolean(ms?.blocked);
|
|
721
|
+
const isPending = isPendingMember(ms?.channel_role as string);
|
|
722
|
+
const isSkipped = isSkippedMember(ms?.channel_role as string);
|
|
680
723
|
|
|
681
|
-
if (!isBannedInChannel && !isBlockedInChannel && !isPending && (chState?.unreadCount as number) > 0) {
|
|
724
|
+
if (!isBannedInChannel && !isBlockedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
|
|
682
725
|
channel.markRead().catch(() => { });
|
|
683
726
|
// Optimistically reset unread to update UI immediately
|
|
684
727
|
if (chState) chState.unreadCount = 0;
|
|
@@ -733,6 +776,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
733
776
|
hiddenActions={hiddenActions}
|
|
734
777
|
actionLabels={actionLabels}
|
|
735
778
|
actionIcons={actionIcons}
|
|
779
|
+
isOnline={getIsOnline(channel)}
|
|
736
780
|
/>
|
|
737
781
|
);
|
|
738
782
|
})}
|
|
@@ -743,7 +787,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
743
787
|
)}
|
|
744
788
|
{regularChannels.map((channel: Channel) => {
|
|
745
789
|
const isActive = activeChannel?.cid === channel.cid;
|
|
746
|
-
const isTeamWithTopics = (channel
|
|
790
|
+
const isTeamWithTopics = hasTopicsEnabled(channel);
|
|
747
791
|
|
|
748
792
|
if (isTeamWithTopics) {
|
|
749
793
|
const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
|
|
@@ -796,6 +840,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
|
|
|
796
840
|
hiddenActions={hiddenActions}
|
|
797
841
|
actionLabels={actionLabels}
|
|
798
842
|
actionIcons={actionIcons}
|
|
843
|
+
isOnline={getIsOnline(channel)}
|
|
799
844
|
/>
|
|
800
845
|
);
|
|
801
846
|
})}
|
|
@@ -4,6 +4,7 @@ import { UserPicker } from './UserPicker';
|
|
|
4
4
|
import { Avatar } from './Avatar';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import type { CreateChannelModalProps, UserPickerUser } from '../types';
|
|
7
|
+
import { isDirectChannel } from '../channelTypeUtils';
|
|
7
8
|
|
|
8
9
|
|
|
9
10
|
export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
@@ -24,8 +25,9 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
24
25
|
cancelButtonLabel = 'Cancel',
|
|
25
26
|
createButtonLabel = 'Create',
|
|
26
27
|
creatingButtonLabel = 'Creating...',
|
|
28
|
+
messageButtonLabel = 'Message',
|
|
27
29
|
}) => {
|
|
28
|
-
const { client } = useChatClient();
|
|
30
|
+
const { client, setActiveChannel } = useChatClient();
|
|
29
31
|
const currentUserId = client?.userID;
|
|
30
32
|
|
|
31
33
|
/* ---------- State ---------- */
|
|
@@ -45,19 +47,20 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
45
47
|
const [error, setError] = useState<string | null>(null);
|
|
46
48
|
|
|
47
49
|
/* ---------- Exclude IDs for Direct ---------- */
|
|
48
|
-
const
|
|
49
|
-
if (!client || !currentUserId || tab !== 'messaging') return
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
Object.values(client.activeChannels).
|
|
53
|
-
if (
|
|
54
|
-
Object.keys(
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
const hasExistingDirectChannel = useMemo(() => {
|
|
51
|
+
if (!client || !currentUserId || tab !== 'messaging' || selectedUsers.length === 0) return false;
|
|
52
|
+
const targetUserId = selectedUsers[0].id;
|
|
53
|
+
|
|
54
|
+
return Object.values(client.activeChannels).some((ch: any) => {
|
|
55
|
+
if (isDirectChannel(ch) && ch.state?.members) {
|
|
56
|
+
const membersList = Object.keys(ch.state.members);
|
|
57
|
+
return membersList.length === 2 &&
|
|
58
|
+
membersList.includes(currentUserId) &&
|
|
59
|
+
membersList.includes(targetUserId);
|
|
57
60
|
}
|
|
61
|
+
return false;
|
|
58
62
|
});
|
|
59
|
-
|
|
60
|
-
}, [client, currentUserId, tab]);
|
|
63
|
+
}, [client, currentUserId, tab, selectedUsers]);
|
|
61
64
|
|
|
62
65
|
/* ---------- Handlers ---------- */
|
|
63
66
|
const handleCreate = useCallback(async () => {
|
|
@@ -82,10 +85,37 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
82
85
|
|
|
83
86
|
if (tab === 'messaging') {
|
|
84
87
|
const targetUserId = selectedUsers[0].id;
|
|
88
|
+
|
|
89
|
+
// Try to find an existing direct channel locally
|
|
90
|
+
const existingChannel = Object.values(client.activeChannels).find((ch: any) => {
|
|
91
|
+
if (isDirectChannel(ch) && ch.state?.members) {
|
|
92
|
+
const membersList = Object.keys(ch.state.members);
|
|
93
|
+
return membersList.length === 2 &&
|
|
94
|
+
membersList.includes(currentUserId) &&
|
|
95
|
+
membersList.includes(targetUserId);
|
|
96
|
+
}
|
|
97
|
+
return false;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
if (existingChannel) {
|
|
101
|
+
if (setActiveChannel) setActiveChannel(existingChannel as any);
|
|
102
|
+
if (onSuccess) {
|
|
103
|
+
onSuccess(existingChannel as any);
|
|
104
|
+
} else {
|
|
105
|
+
onClose();
|
|
106
|
+
}
|
|
107
|
+
setIsCreating(false);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
85
111
|
createdChannel = client.channel('messaging', {
|
|
86
112
|
members: [currentUserId, targetUserId],
|
|
87
113
|
} as any);
|
|
88
|
-
await createdChannel.create();
|
|
114
|
+
const response = (await createdChannel.create()) as any;
|
|
115
|
+
if (response?.channel?.id) {
|
|
116
|
+
createdChannel = client.channel('messaging', response.channel.id);
|
|
117
|
+
await createdChannel.watch();
|
|
118
|
+
}
|
|
89
119
|
} else {
|
|
90
120
|
// Group Channel
|
|
91
121
|
const memberIds = selectedUsers.map(member => member.id);
|
|
@@ -105,7 +135,15 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
105
135
|
}
|
|
106
136
|
|
|
107
137
|
createdChannel = client.channel('team', payload);
|
|
108
|
-
await createdChannel.create();
|
|
138
|
+
const response = (await createdChannel.create()) as any;
|
|
139
|
+
if (response?.channel?.id) {
|
|
140
|
+
createdChannel = client.channel('team', response.channel.id);
|
|
141
|
+
await createdChannel.watch();
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (setActiveChannel) {
|
|
146
|
+
setActiveChannel(createdChannel);
|
|
109
147
|
}
|
|
110
148
|
|
|
111
149
|
// Cleanup and execute callback
|
|
@@ -136,7 +174,7 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
136
174
|
<div className="ermis-create-channel__footer">
|
|
137
175
|
<button className="ermis-create-channel__btn ermis-create-channel__btn--cancel" onClick={onClose} disabled={isCreating}>{cancelButtonLabel}</button>
|
|
138
176
|
<button className="ermis-create-channel__btn ermis-create-channel__btn--create" onClick={handleCreate} disabled={isCreating || !isValid}>
|
|
139
|
-
{isCreating ? creatingButtonLabel : createButtonLabel}
|
|
177
|
+
{isCreating ? creatingButtonLabel : (hasExistingDirectChannel ? messageButtonLabel : createButtonLabel)}
|
|
140
178
|
</button>
|
|
141
179
|
</div>
|
|
142
180
|
);
|
|
@@ -247,7 +285,6 @@ export const CreateChannelModal: React.FC<CreateChannelModalProps> = ({
|
|
|
247
285
|
<UserPicker
|
|
248
286
|
mode={tab === 'messaging' ? 'radio' : 'checkbox'}
|
|
249
287
|
onSelectionChange={setSelectedUsers}
|
|
250
|
-
excludeUserIds={tab === 'messaging' ? existingDirectUserIds : []}
|
|
251
288
|
initialSelectedUsers={selectedUsers}
|
|
252
289
|
AvatarComponent={AvatarComponent}
|
|
253
290
|
UserItemComponent={UserItemComponent as any}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
6
|
|
|
6
7
|
const MAX_PREVIEW_LENGTH = 120;
|
|
@@ -57,7 +58,7 @@ export const EditPreview: React.FC<{
|
|
|
57
58
|
const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
|
|
58
59
|
const hasText = !!formattedText.trim();
|
|
59
60
|
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
60
|
-
const isSticker = message
|
|
61
|
+
const isSticker = isStickerMessage(message);
|
|
61
62
|
const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
|
|
62
63
|
|
|
63
64
|
// Build preview content
|
|
@@ -5,6 +5,7 @@ import { useChatClient } from '../hooks/useChatClient';
|
|
|
5
5
|
import { Avatar } from './Avatar';
|
|
6
6
|
import { Modal } from './Modal';
|
|
7
7
|
import type { ForwardMessageModalProps, ForwardChannelItemProps, AvatarProps } from '../types';
|
|
8
|
+
import { isTopicChannel } from '../channelTypeUtils';
|
|
8
9
|
|
|
9
10
|
export type { ForwardMessageModalProps, ForwardChannelItemProps } from '../types';
|
|
10
11
|
|
|
@@ -66,7 +67,7 @@ export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
|
|
|
66
67
|
/* ---------- Get channels from client state (exclude topics) ---------- */
|
|
67
68
|
const channels = useMemo(() => {
|
|
68
69
|
return (Object.values(client.activeChannels) as Channel[]).filter(
|
|
69
|
-
(ch) => ch
|
|
70
|
+
(ch) => !isTopicChannel(ch),
|
|
70
71
|
);
|
|
71
72
|
}, [client.activeChannels]);
|
|
72
73
|
|