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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/index.cjs +2780 -1852
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +364 -8
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +160 -1
  6. package/dist/index.d.ts +160 -1
  7. package/dist/index.mjs +2780 -1884
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +2 -2
  10. package/src/channelRoleUtils.ts +73 -0
  11. package/src/channelTypeUtils.ts +46 -0
  12. package/src/components/Avatar.tsx +57 -31
  13. package/src/components/ChannelActions.tsx +13 -11
  14. package/src/components/ChannelHeader.tsx +89 -4
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +23 -17
  16. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +57 -26
  17. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +4 -2
  18. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -1
  19. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  20. package/src/components/ChannelList.tsx +59 -14
  21. package/src/components/CreateChannelModal.tsx +53 -16
  22. package/src/components/EditPreview.tsx +2 -1
  23. package/src/components/ForwardMessageModal.tsx +2 -1
  24. package/src/components/MediaLightbox.tsx +314 -0
  25. package/src/components/MessageInput.tsx +3 -2
  26. package/src/components/MessageItem.tsx +2 -1
  27. package/src/components/MessageRenderers.tsx +168 -46
  28. package/src/components/PendingOverlay.tsx +11 -1
  29. package/src/components/PinnedMessages.tsx +2 -1
  30. package/src/components/ReplyPreview.tsx +2 -1
  31. package/src/components/SkippedOverlay.tsx +36 -0
  32. package/src/components/UserPicker.tsx +1 -1
  33. package/src/components/VirtualMessageList.tsx +91 -7
  34. package/src/hooks/useBlockedState.ts +3 -2
  35. package/src/hooks/useChannelCapabilities.ts +10 -12
  36. package/src/hooks/useChannelListUpdates.ts +6 -4
  37. package/src/hooks/useChannelMessages.ts +2 -3
  38. package/src/hooks/useChannelRowUpdates.ts +3 -2
  39. package/src/hooks/useMessageActions.ts +23 -9
  40. package/src/hooks/useOnlineStatus.ts +71 -0
  41. package/src/hooks/useOnlineUsers.ts +115 -0
  42. package/src/hooks/usePendingState.ts +8 -3
  43. package/src/index.ts +61 -9
  44. package/src/messageTypeUtils.ts +64 -0
  45. package/src/styles/_channel-list.css +59 -0
  46. package/src/styles/_media-lightbox.css +263 -0
  47. package/src/styles/_message-bubble.css +99 -8
  48. package/src/styles/_message-list.css +25 -0
  49. package/src/styles/index.css +1 -0
  50. 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 type { ChannelInfoTabsProps, MediaTab, AttachmentItem } from '../../types';
11
+ import { MediaLightbox } from '../MediaLightbox';
12
+ import type { ChannelInfoTabsProps, MediaTab, AttachmentItem, MediaLightboxItem } from '../../types';
13
+ import { isDirectChannel } from '../../channelTypeUtils';
14
+ import {
15
+ CHANNEL_ROLES,
16
+ canRemoveTargetMember,
17
+ canBanTargetMember,
18
+ canPromoteTargetMember,
19
+ canDemoteTargetMember
20
+ } from '../../channelRoleUtils';
12
21
 
13
22
  export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo(({
14
23
  channel,
@@ -31,7 +40,7 @@ export const DefaultChannelInfoTabs: React.FC<ChannelInfoTabsProps> = React.memo
31
40
  EmptyStateComponent,
32
41
  LoadingComponent,
33
42
  }) => {
34
- const isMessaging = channel?.type === 'messaging';
43
+ const isMessaging = isDirectChannel(channel);
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 || 'member'] || 0;
72
- const bWeight = ROLE_WEIGHTS[b.channel_role || 'member'] || 0;
80
+ const aWeight = ROLE_WEIGHTS[a.channel_role || CHANNEL_ROLES.MEMBER] || 0;
81
+ const bWeight = ROLE_WEIGHTS[b.channel_role || CHANNEL_ROLES.MEMBER] || 0;
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 || 'member';
175
- const isTargetRemovable = role === 'member' || role === 'pending' || (currentUserRole === 'owner' && role === 'moder');
208
+ const role = member.channel_role || CHANNEL_ROLES.MEMBER;
209
+ const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
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 === 'owner' || currentUserRole === 'moder') &&
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 === 'owner' || currentUserRole === 'moder') &&
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 = Boolean(
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 = Boolean(
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={handleOpenUrl} />
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={handleOpenUrl} />
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 === 'owner';
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?.type === 'team' || channel?.type === 'meeting') && (
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.type === 'team' || channel.type === 'meeting';
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 || 'member';
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
- <AvatarComponent image={image} name={name} size={40} />
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 pending
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 = Boolean(userRole === 'owner' || userRole === 'moder');
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 === 'pending' || ms?.role === 'pending';
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.type === 'messaging' && Boolean(ms?.blocked);
679
- const isPending = ms?.channel_role === 'pending' || ms?.role === 'pending';
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.type === 'team' || channel.type === 'meeting') && channel.data?.topics_enabled;
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 existingDirectUserIds = useMemo(() => {
49
- if (!client || !currentUserId || tab !== 'messaging') return [];
50
-
51
- const ids = new Set<string>();
52
- Object.values(client.activeChannels).forEach((channel: any) => {
53
- if (channel.type === 'messaging' && channel.state?.members) {
54
- Object.keys(channel.state.members).forEach(uid => {
55
- if (uid !== currentUserId) ids.add(uid);
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
- return Array.from(ids);
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.type === 'sticker';
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.type !== 'topic',
70
+ (ch) => !isTopicChannel(ch),
70
71
  );
71
72
  }, [client.activeChannels]);
72
73