@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ermis-network/ermis-chat-react",
3
- "version": "1.0.7",
3
+ "version": "1.0.8",
4
4
  "description": "React UI components for Ermis Chat",
5
5
  "author": "Ermis",
6
6
  "homepage": "https://ermis.network/",
@@ -20,7 +20,7 @@
20
20
  "/src"
21
21
  ],
22
22
  "dependencies": {
23
- "@ermis-network/ermis-chat-sdk": "1.0.7",
23
+ "@ermis-network/ermis-chat-sdk": "1.0.8",
24
24
  "virtua": "^0.48.8"
25
25
  },
26
26
  "peerDependencies": {
@@ -0,0 +1,73 @@
1
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
2
+ import { isDirectChannel } from './channelTypeUtils';
3
+
4
+ export const CHANNEL_ROLES = {
5
+ OWNER: 'owner',
6
+ MODERATOR: 'moder',
7
+ MEMBER: 'member',
8
+ PENDING: 'pending',
9
+ SKIPPED: 'skipped',
10
+ } as const;
11
+
12
+ export type ChannelRole = typeof CHANNEL_ROLES[keyof typeof CHANNEL_ROLES] | string;
13
+
14
+ /** Checks if the user is in a pending state */
15
+ export function isPendingMember(role?: string): boolean {
16
+ return role === CHANNEL_ROLES.PENDING;
17
+ }
18
+
19
+ /** Checks if the user is in a skipped state (skipped a direct message invite) */
20
+ export function isSkippedMember(role?: string): boolean {
21
+ return role === CHANNEL_ROLES.SKIPPED;
22
+ }
23
+
24
+ /** Checks if the user has management permissions (owner or moderator) */
25
+ export function canManageChannel(role?: string): boolean {
26
+ return role === CHANNEL_ROLES.OWNER || role === CHANNEL_ROLES.MODERATOR;
27
+ }
28
+
29
+ /** Determines if the current user has the permission to remove a specific target member */
30
+ export function canRemoveTargetMember(currentUserRole?: string, targetRole?: string): boolean {
31
+ const isTargetRemovable =
32
+ targetRole === CHANNEL_ROLES.MEMBER ||
33
+ targetRole === CHANNEL_ROLES.PENDING ||
34
+ (currentUserRole === CHANNEL_ROLES.OWNER && targetRole === CHANNEL_ROLES.MODERATOR);
35
+
36
+ return canManageChannel(currentUserRole) && isTargetRemovable;
37
+ }
38
+
39
+ /** Determines if the current user has the permission to ban a specific target member */
40
+ export function canBanTargetMember(currentUserRole?: string, targetRole?: string): boolean {
41
+ return canRemoveTargetMember(currentUserRole, targetRole) && targetRole !== CHANNEL_ROLES.PENDING;
42
+ }
43
+
44
+ /** Determines if the current user has the permission to promote a member to moderator */
45
+ export function canPromoteTargetMember(currentUserRole?: string, targetRole?: string): boolean {
46
+ return currentUserRole === CHANNEL_ROLES.OWNER && targetRole === CHANNEL_ROLES.MEMBER;
47
+ }
48
+
49
+ /** Determines if the current user has the permission to demote a moderator to simple member */
50
+ export function canDemoteTargetMember(currentUserRole?: string, targetRole?: string): boolean {
51
+ return currentUserRole === CHANNEL_ROLES.OWNER && targetRole === CHANNEL_ROLES.MODERATOR;
52
+ }
53
+
54
+ /** Checks if the user is an owner of the channel */
55
+ export function isOwnerMember(role?: string): boolean {
56
+ return role === CHANNEL_ROLES.OWNER;
57
+ }
58
+
59
+ /**
60
+ * Checks if a direct channel represents a "friend" relationship:
61
+ * both members must have the 'owner' channel_role.
62
+ */
63
+ export function isFriendChannel(
64
+ channel: Channel | null | undefined,
65
+ targetUserId: string,
66
+ currentUserId: string,
67
+ ): boolean {
68
+ if (!channel || !isDirectChannel(channel)) return false;
69
+ const targetMember = channel.state?.members?.[targetUserId];
70
+ const currentMember = channel.state?.members?.[currentUserId];
71
+ return isOwnerMember(targetMember?.channel_role as string)
72
+ && isOwnerMember(currentMember?.channel_role as string);
73
+ }
@@ -0,0 +1,46 @@
1
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
2
+
3
+ // ─── Group Channel Types ───────────────────────────
4
+ // Types that behave like group/team channels (roles, capabilities, settings, topics)
5
+ const GROUP_CHANNEL_TYPES = new Set(['team', 'meeting']);
6
+
7
+ // ─── Direct Channel Types ──────────────────────────
8
+ // Types that behave like 1-on-1 direct messaging (block/unblock)
9
+ const DIRECT_CHANNEL_TYPES = new Set(['messaging']);
10
+
11
+ // ─── Semantic Helpers ──────────────────────────────
12
+
13
+ /** Channel supports group features: roles, capabilities, settings, topics, edit, delete */
14
+ export function isGroupChannel(channel: Channel | null | undefined): boolean {
15
+ return channel ? GROUP_CHANNEL_TYPES.has(channel.type) : false;
16
+ }
17
+
18
+ /** Channel is a direct (1-on-1) conversation: block/unblock, no roles */
19
+ export function isDirectChannel(channel: Channel | null | undefined): boolean {
20
+ return channel ? DIRECT_CHANNEL_TYPES.has(channel.type) : false;
21
+ }
22
+
23
+ /** Channel is a topic (sub-channel of a group channel) */
24
+ export function isTopicChannel(channel: Channel | null | undefined): boolean {
25
+ return channel ? (channel.type === 'topic' || Boolean(channel.data?.parent_cid)) : false;
26
+ }
27
+
28
+ /** Channel is a public group that users can join without invite */
29
+ export function isPublicGroupChannel(channel: Channel | null | undefined): boolean {
30
+ return isGroupChannel(channel) && Boolean(channel?.data?.public);
31
+ }
32
+
33
+ /** The proxy "general" channel of a topics-enabled group */
34
+ export function isGeneralProxy(channel: Channel | null | undefined): boolean {
35
+ return isGroupChannel(channel) && channel?.data?.name === 'general';
36
+ }
37
+
38
+ /** Channel has topics feature enabled */
39
+ export function hasTopicsEnabled(channel: Channel | null | undefined): boolean {
40
+ return isGroupChannel(channel) && Boolean(channel?.data?.topics_enabled);
41
+ }
42
+
43
+ /** Whether blocked state is relevant for this channel type */
44
+ export function supportsBlocking(channel: Channel | null | undefined): boolean {
45
+ return isDirectChannel(channel);
46
+ }
@@ -1,4 +1,5 @@
1
- import React, { useMemo } from 'react';
1
+ import React, { useMemo, useState } from 'react';
2
+ import { MediaLightbox } from './MediaLightbox';
2
3
  import type { AvatarProps } from '../types';
3
4
 
4
5
  export type { AvatarProps } from '../types';
@@ -24,9 +25,11 @@ export const Avatar: React.FC<AvatarProps> = React.memo(({
24
25
  name,
25
26
  size = 36,
26
27
  className,
28
+ disableLightbox,
27
29
  }) => {
28
- const [isLoaded, setIsLoaded] = React.useState(false);
29
- const [hasError, setHasError] = React.useState(false);
30
+ const [isLoaded, setIsLoaded] = useState(false);
31
+ const [hasError, setHasError] = useState(false);
32
+ const [isLightboxOpen, setIsLightboxOpen] = useState(false);
30
33
  const imgRef = React.useRef<HTMLImageElement>(null);
31
34
 
32
35
  // Reset state if image URL changes
@@ -54,7 +57,8 @@ export const Avatar: React.FC<AvatarProps> = React.memo(({
54
57
  display: 'flex',
55
58
  alignItems: 'center',
56
59
  justifyContent: 'center',
57
- }), [size]);
60
+ cursor: image && !hasError && !disableLightbox ? 'pointer' : undefined,
61
+ }), [size, image, hasError, disableLightbox]);
58
62
 
59
63
  const contentStyle = useMemo<React.CSSProperties>(() => ({
60
64
  width: '100%',
@@ -63,39 +67,61 @@ export const Avatar: React.FC<AvatarProps> = React.memo(({
63
67
  lineHeight: 1,
64
68
  }), [size]);
65
69
 
70
+ const handleAvatarClick = React.useCallback((e: React.MouseEvent) => {
71
+ if (image && !hasError && !disableLightbox) {
72
+ e.stopPropagation();
73
+ e.preventDefault();
74
+ setIsLightboxOpen(true);
75
+ }
76
+ }, [image, hasError, disableLightbox]);
77
+
66
78
  return (
67
- <div className={`ermis-avatar-wrapper${className ? ` ${className}` : ''}`} style={wrapperStyle}>
68
- {/* 1. Underlying Fallback (Placeholder) */}
69
- <div
70
- className="ermis-avatar ermis-avatar--fallback"
71
- style={contentStyle}
72
- title={name}
79
+ <>
80
+ <div
81
+ className={`ermis-avatar-wrapper${className ? ` ${className}` : ''}`}
82
+ style={wrapperStyle}
83
+ onClick={handleAvatarClick}
73
84
  >
74
- {initials}
85
+ {/* 1. Underlying Fallback (Placeholder) */}
86
+ <div
87
+ className="ermis-avatar ermis-avatar--fallback"
88
+ style={contentStyle}
89
+ title={name}
90
+ >
91
+ {initials}
92
+ </div>
93
+
94
+ {/* 2. Actual Image (Lazy, Fades in natively using CSS opacity) */}
95
+ {image && !hasError && (
96
+ <img
97
+ ref={imgRef}
98
+ className="ermis-avatar__img"
99
+ src={image}
100
+ alt={name || 'Avatar'}
101
+ loading="lazy"
102
+ onLoad={() => setIsLoaded(true)}
103
+ onError={() => setHasError(true)}
104
+ style={{
105
+ ...contentStyle,
106
+ position: 'absolute',
107
+ top: 0,
108
+ left: 0,
109
+ opacity: isLoaded ? 1 : 0,
110
+ transition: 'opacity 0.3s ease-in-out',
111
+ objectFit: 'cover',
112
+ }}
113
+ />
114
+ )}
75
115
  </div>
76
116
 
77
- {/* 2. Actual Image (Lazy, Fades in natively using CSS opacity) */}
78
- {image && !hasError && (
79
- <img
80
- ref={imgRef}
81
- className="ermis-avatar__img"
82
- src={image}
83
- alt={name || 'Avatar'}
84
- loading="lazy"
85
- onLoad={() => setIsLoaded(true)}
86
- onError={() => setHasError(true)}
87
- style={{
88
- ...contentStyle,
89
- position: 'absolute',
90
- top: 0,
91
- left: 0,
92
- opacity: isLoaded ? 1 : 0,
93
- transition: 'opacity 0.3s ease-in-out',
94
- objectFit: 'cover',
95
- }}
117
+ {isLightboxOpen && image && !hasError && (
118
+ <MediaLightbox
119
+ items={[{ type: 'image', src: image, alt: name || 'Avatar' }]}
120
+ isOpen={isLightboxOpen}
121
+ onClose={() => setIsLightboxOpen(false)}
96
122
  />
97
123
  )}
98
- </div>
124
+ </>
99
125
  );
100
126
  });
101
127
 
@@ -2,6 +2,8 @@ import React, { useState, useCallback, useMemo } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
3
  import type { ChannelAction, ChannelActionLabels, ChannelActionIcons, ChannelActionsProps } from '../types';
4
4
  import { Dropdown } from './Dropdown';
5
+ import { isDirectChannel, isGroupChannel, isTopicChannel } from '../channelTypeUtils';
6
+ import { canManageChannel, CHANNEL_ROLES } from '../channelRoleUtils';
5
7
 
6
8
  /* ----------------------------------------------------------
7
9
  SVG Icons for default actions
@@ -38,13 +40,13 @@ export function computeDefaultActions(
38
40
  const actions: ChannelAction[] = [];
39
41
  if (!currentUserId) return actions;
40
42
 
41
- const isDirect = channel.type === 'messaging';
42
- const isTeamOrMeeting = channel.type === 'team' || channel.type === 'meeting';
43
- const isTopic = channel.type === 'topic' || Boolean(channel.data?.parent_cid);
43
+ const isDirect = isDirectChannel(channel);
44
+ const isTeamOrMeeting = isGroupChannel(channel);
45
+ const isTopic = isTopicChannel(channel);
44
46
  const isClosed = channel.data?.is_closed_topic === true;
45
47
 
46
48
  const ms = channel.state?.members?.[currentUserId] || channel.state?.membership;
47
- const role = ms?.channel_role || (ms as any)?.role;
49
+ const role = ms?.channel_role;
48
50
  const isBlocked = options?.isBlocked !== undefined ? options.isBlocked : (ms as any)?.blocked;
49
51
  const isPinned = channel.data?.is_pinned === true;
50
52
 
@@ -57,8 +59,8 @@ export function computeDefaultActions(
57
59
 
58
60
  const actionIcons = options?.actionIcons;
59
61
 
60
- const pinIcon = isPinned
61
- ? (actionIcons?.UnpinIcon || <UnpinIcon />)
62
+ const pinIcon = isPinned
63
+ ? (actionIcons?.UnpinIcon || <UnpinIcon />)
62
64
  : (actionIcons?.PinIcon || <PinIcon />);
63
65
 
64
66
  actions.push({
@@ -99,7 +101,7 @@ export function computeDefaultActions(
99
101
  });
100
102
  } else if (isTopic) {
101
103
  // Topic: Edit topic (owner & moder only)
102
- if (role === 'owner' || role === 'moder') {
104
+ if (canManageChannel(role)) {
103
105
  actions.push({
104
106
  id: 'edit_topic',
105
107
  label: actionLabels?.editTopic || 'Edit topic',
@@ -110,7 +112,7 @@ export function computeDefaultActions(
110
112
  });
111
113
  }
112
114
  // Topic: Close / Reopen (owner & moder only)
113
- if (role === 'owner' || role === 'moder') {
115
+ if (canManageChannel(role)) {
114
116
  actions.push({
115
117
  id: isClosed ? 'reopen' : 'close',
116
118
  label: isClosed ? (actionLabels?.reopenTopic || 'Reopen topic') : (actionLabels?.closeTopic || 'Close topic'),
@@ -124,7 +126,7 @@ export function computeDefaultActions(
124
126
  } else if (isTeamOrMeeting) {
125
127
  // Team channel: Create Topic (owner & moder, only if topics enabled)
126
128
  const hasTopicsEnabled = Boolean(channel.data?.topics_enabled);
127
- if (hasTopicsEnabled && (role === 'owner' || role === 'moder') && options?.onAddTopic) {
129
+ if (hasTopicsEnabled && canManageChannel(role) && options?.onAddTopic) {
128
130
  actions.push({
129
131
  id: 'create_topic',
130
132
  label: actionLabels?.createTopic || 'Create topic',
@@ -132,7 +134,7 @@ export function computeDefaultActions(
132
134
  onClick: (ch) => { options.onAddTopic!(ch); },
133
135
  });
134
136
  }
135
- if (role === 'owner') {
137
+ if (role === CHANNEL_ROLES.OWNER) {
136
138
  actions.push({
137
139
  id: 'delete',
138
140
  label: actionLabels?.deleteChannel || 'Delete channel',
@@ -147,7 +149,7 @@ export function computeDefaultActions(
147
149
  },
148
150
  });
149
151
  }
150
- if (role === 'moder' || role === 'member') {
152
+ if (role === CHANNEL_ROLES.MODERATOR || role === CHANNEL_ROLES.MEMBER) {
151
153
  actions.push({
152
154
  id: 'leave',
153
155
  label: actionLabels?.leaveChannel || 'Leave channel',
@@ -3,7 +3,11 @@ import { useChatClient } from '../hooks/useChatClient';
3
3
  import { usePendingState } from '../hooks/usePendingState';
4
4
  import { Avatar } from './Avatar';
5
5
  import type { ChannelHeaderProps } from '../types';
6
+ import type { OnlineStatus } from '../hooks/useOnlineStatus';
6
7
  import { ErmisCallContext } from '../context/ErmisCallContext';
8
+ import { hasTopicsEnabled, isDirectChannel } from '../channelTypeUtils';
9
+ import { isSkippedMember, isFriendChannel } from '../channelRoleUtils';
10
+ import type { Event } from '@ermis-network/ermis-chat-sdk';
7
11
 
8
12
  export type { ChannelHeaderProps } from '../types';
9
13
 
@@ -16,6 +20,8 @@ export type { ChannelHeaderProps } from '../types';
16
20
  * - `AvatarComponent` — replace the avatar
17
21
  * - `renderTitle(channel)` — fully custom title rendering
18
22
  * - `renderRight(channel)` — render content on the right side
23
+ * - `showOnlineStatus` — show online/offline dot for friend channels (default: true)
24
+ * - `OnlineIndicatorComponent` — replace the default indicator
19
25
  *
20
26
  * For a fully custom header, use `Channel`'s `HeaderComponent` prop instead.
21
27
  */
@@ -32,12 +38,21 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
32
38
  audioCallTitle = 'Audio Call',
33
39
  videoCallTitle = 'Video Call',
34
40
  CallBadgeComponent,
41
+ showOnlineStatus = true,
42
+ onlineLabel = 'Online',
43
+ offlineLabel = 'Offline',
44
+ OnlineIndicatorComponent,
35
45
  }) => {
36
46
  const { activeChannel, client, enableCall } = useChatClient();
37
47
  const { isPending } = usePendingState(activeChannel, client.userID);
38
48
  const callContext = useContext(ErmisCallContext);
39
49
 
40
- const actionDisabled = isPending;
50
+ const isSkipped = client.userID
51
+ ? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
52
+ isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
53
+ : false;
54
+
55
+ const actionDisabled = isPending || isSkipped;
41
56
 
42
57
  // Force re-render when channel.updated WS event fires
43
58
  const [channelUpdateCount, setChannelUpdateCount] = useState(0);
@@ -71,7 +86,7 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
71
86
 
72
87
  // If it's a topics-enabled team channel (the general proxy), the proxy overrides data.name.
73
88
  // We can pull the original name from the SDK cache.
74
- if ((activeChannel.type === 'team' || activeChannel.type === 'meeting') && activeChannel.data?.topics_enabled) {
89
+ if (hasTopicsEnabled(activeChannel)) {
75
90
  const rawChannel = client.activeChannels[activeChannel.cid];
76
91
  if (rawChannel && rawChannel.data?.name && rawChannel.data.name !== activeChannel.data?.name) {
77
92
  return rawChannel.data.name;
@@ -81,6 +96,62 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
81
96
  return undefined;
82
97
  }, [activeChannel, client.activeChannels]);
83
98
 
99
+ // ── Online Status (direct friend channels only) ──
100
+ const currentUserId = client.userID;
101
+
102
+ // Get the "other" user's ID from the direct channel.
103
+ const otherUserId = useMemo(() => {
104
+ if (!activeChannel || !currentUserId || !isDirectChannel(activeChannel)) return undefined;
105
+ const members = activeChannel.state?.members;
106
+ if (!members) return undefined;
107
+ for (const memberId of Object.keys(members)) {
108
+ if (memberId !== currentUserId) return memberId;
109
+ }
110
+ return undefined;
111
+ }, [activeChannel, currentUserId]);
112
+
113
+ // Check if this is a friend channel (both members are owner).
114
+ const isFriend = useMemo(() => {
115
+ if (!otherUserId || !currentUserId || !activeChannel) return false;
116
+ return isFriendChannel(activeChannel, otherUserId, currentUserId);
117
+ }, [activeChannel, otherUserId, currentUserId]);
118
+
119
+ // Derive online status from watchers + subscribe to realtime events.
120
+ const [onlineStatus, setOnlineStatus] = useState<OnlineStatus>('unknown');
121
+
122
+ useEffect(() => {
123
+ if (!showOnlineStatus || !isFriend || !otherUserId || !activeChannel) {
124
+ setOnlineStatus('unknown');
125
+ return;
126
+ }
127
+
128
+ // Read initial state from watchers.
129
+ setOnlineStatus(activeChannel.state?.watchers?.[otherUserId] ? 'online' : 'offline');
130
+
131
+ const handleWatchingStart = (event: Event) => {
132
+ if (event.user?.id === otherUserId) {
133
+ setOnlineStatus('online');
134
+ }
135
+ };
136
+
137
+ const handleWatchingStop = (event: Event) => {
138
+ if (event.user?.id === otherUserId) {
139
+ setOnlineStatus('offline');
140
+ }
141
+ };
142
+
143
+ const sub1 = activeChannel.on('user.watching.start', handleWatchingStart);
144
+ const sub2 = activeChannel.on('user.watching.stop', handleWatchingStop);
145
+
146
+ return () => {
147
+ sub1.unsubscribe();
148
+ sub2.unsubscribe();
149
+ };
150
+ }, [activeChannel, otherUserId, isFriend, showOnlineStatus]);
151
+
152
+ const showOnlineDot = showOnlineStatus && onlineStatus !== 'unknown';
153
+ const isOnline = onlineStatus === 'online';
154
+
84
155
  if (!activeChannel) return null;
85
156
 
86
157
  return (
@@ -108,14 +179,28 @@ export const ChannelHeader: React.FC<ChannelHeaderProps> = React.memo(({
108
179
  <div className="ermis-channel-header__name">{channelName}</div>
109
180
  </div>
110
181
  )}
111
- {subtitle && (
182
+ {/* Online/Offline indicator for friend direct channels */}
183
+ {showOnlineDot && (
184
+ OnlineIndicatorComponent ? (
185
+ <OnlineIndicatorComponent isOnline={isOnline} />
186
+ ) : (
187
+ <div className={`ermis-channel-header__online-status ermis-channel-header__online-status--${isOnline ? 'online' : 'offline'}`}>
188
+ <span className={`ermis-channel-header__online-dot ermis-channel-header__online-dot--${isOnline ? 'online' : 'offline'}`} />
189
+ <span className="ermis-channel-header__online-label">
190
+ {isOnline ? onlineLabel : offlineLabel}
191
+ </span>
192
+ </div>
193
+ )
194
+ )}
195
+ {/* Consumer-provided subtitle (takes over if set) */}
196
+ {subtitle && !showOnlineDot && (
112
197
  <div className="ermis-channel-header__subtitle">{subtitle}</div>
113
198
  )}
114
199
  </div>
115
200
 
116
201
  {/* renderRight exposes actionDisabled for consumers to disable UI features natively */}
117
202
  <div className="ermis-channel-header__actions">
118
- {enableCall && callContext && activeChannel?.type === 'messaging' && !isPending && (
203
+ {enableCall && callContext && isDirectChannel(activeChannel) && !isPending && !isSkipped && (
119
204
  <>
120
205
  {renderAudioCallButton ? (
121
206
  renderAudioCallButton(() => callContext.createCall('audio', activeChannel.cid || ''), actionDisabled)
@@ -16,6 +16,8 @@ import type {
16
16
  ChannelInfoActionsProps,
17
17
  } from '../../types';
18
18
  import { useChannelMembers, useChannelProfile } from '../../hooks/useChannelData';
19
+ import { isGroupChannel, isTopicChannel } from '../../channelTypeUtils';
20
+ import { canManageChannel, CHANNEL_ROLES } from '../../channelRoleUtils';
19
21
 
20
22
  export const DefaultChannelInfoHeader: React.FC<ChannelInfoHeaderProps> = React.memo(({ title, onClose }) => {
21
23
  return (
@@ -108,7 +110,7 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
108
110
  </div>
109
111
  <span>{searchLabel}</span>
110
112
  </button>
111
- {isTeamChannel && (currentUserRole === 'owner' || currentUserRole === 'moder') && (
113
+ {isTeamChannel && canManageChannel(currentUserRole) && (
112
114
  <button className="ermis-channel-info__action-btn" onClick={onSettingsClick}>
113
115
  <div className="ermis-channel-info__action-icon">
114
116
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -120,7 +122,7 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
120
122
  </button>
121
123
  )}
122
124
  {isTeamChannel && (
123
- currentUserRole === 'owner' ? (
125
+ currentUserRole === CHANNEL_ROLES.OWNER ? (
124
126
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onDeleteChannel}>
125
127
  <div className="ermis-channel-info__action-icon">
126
128
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
@@ -144,7 +146,7 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
144
146
  )
145
147
  )}
146
148
  {/* Topics: Close/Reopen Topic for owner/moder */}
147
- {isTopic && (currentUserRole === 'owner' || currentUserRole === 'moder') && (
149
+ {isTopic && canManageChannel(currentUserRole) && (
148
150
  isClosedTopic ? (
149
151
  <button className="ermis-channel-info__action-btn" onClick={onReopenTopic}>
150
152
  <div className="ermis-channel-info__action-icon">
@@ -273,8 +275,8 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
273
275
 
274
276
  const currentUserId = client?.userID;
275
277
  const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
276
- const isTeamChannel = channel?.type === 'team' || channel?.type === 'meeting';
277
- const isTopic = Boolean(channel?.data?.parent_cid) || channel?.type === 'topic';
278
+ const isTeamChannel = isGroupChannel(channel);
279
+ const isTopic = isTopicChannel(channel);
278
280
  const isClosedTopic = channel?.data?.is_closed_topic === true;
279
281
  const title = titleProp !== undefined ? titleProp : (isTopic ? 'Topic Info' : 'Channel Info');
280
282
 
@@ -282,14 +284,6 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
282
284
  const parentChannel = parentCid && client ? client.activeChannels[parentCid] : undefined;
283
285
  let parentChannelName = parentChannel?.data?.name || (parentCid ? 'Unknown' : undefined);
284
286
 
285
- // If this is the proxy 'general' channel, its real name is the parent team name
286
- if ((channel?.type === 'team' || channel?.type === 'meeting') && channel?.data?.name === 'general' && channel.cid) {
287
- const realChannelName = client?.activeChannels[channel.cid]?.data?.name;
288
- if (realChannelName && realChannelName !== 'general') {
289
- parentChannelName = realChannelName;
290
- }
291
- }
292
-
293
287
  const handleDeleteChannel = useCallback(async () => {
294
288
  if (onDeleteChannelProp) return onDeleteChannelProp();
295
289
  if (!channel) return;
@@ -367,7 +361,19 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
367
361
  }, [channel, parentChannel]);
368
362
 
369
363
  const { members } = useChannelMembers(channel);
370
- const { channelName, channelImage, channelDescription } = useChannelProfile(channel);
364
+ const { channelName: profileChannelName, channelImage, channelDescription } = useChannelProfile(channel);
365
+
366
+ let finalChannelName = profileChannelName;
367
+ let finalParentChannelName = parentChannelName;
368
+
369
+ // If this is the proxy 'general' channel, show the team name as the main name and hide the parent name.
370
+ if (isGroupChannel(channel) && channel?.data?.name === 'general' && channel.cid) {
371
+ const realChannelName = client?.activeChannels[channel.cid]?.data?.name;
372
+ if (realChannelName && realChannelName !== 'general') {
373
+ finalChannelName = realChannelName;
374
+ finalParentChannelName = undefined;
375
+ }
376
+ }
371
377
 
372
378
  const [showAddMemberModal, setShowAddMemberModal] = useState(false);
373
379
  const [showEditChannelModal, setShowEditChannelModal] = useState(false);
@@ -376,7 +382,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
376
382
  const [showSettingsPanel, setShowSettingsPanel] = useState(false);
377
383
 
378
384
  // Permission: only owner or moderator can edit channel info (banned users cannot)
379
- const canEditChannel = (isTeamChannel || isTopic) && !isBanned && (currentUserRole === 'owner' || currentUserRole === 'moder');
385
+ const canEditChannel = (isTeamChannel || isTopic) && !isBanned && canManageChannel(currentUserRole);
380
386
 
381
387
  const handleEditChannelClick = useCallback(() => {
382
388
  if (isTopic) {
@@ -400,7 +406,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
400
406
  <HeaderComponent title={title} onClose={onClose} />
401
407
 
402
408
  <CoverComponent
403
- channelName={channelName}
409
+ channelName={finalChannelName}
404
410
  channelImage={channelImage}
405
411
  channelDescription={channelDescription}
406
412
  AvatarComponent={AvatarComponent}
@@ -408,7 +414,7 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
408
414
  onEditClick={handleEditChannelClick}
409
415
  isPublic={Boolean(channel?.data?.public)}
410
416
  isTeamChannel={isTeamChannel}
411
- parentChannelName={parentChannelName}
417
+ parentChannelName={finalParentChannelName}
412
418
  isTopic={isTopic}
413
419
  />
414
420