@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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ermis-network/ermis-chat-react",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.9",
|
|
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.
|
|
23
|
+
"@ermis-network/ermis-chat-sdk": "1.0.9",
|
|
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] =
|
|
29
|
-
const [hasError, setHasError] =
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
title={name}
|
|
79
|
+
<>
|
|
80
|
+
<div
|
|
81
|
+
className={`ermis-avatar-wrapper${className ? ` ${className}` : ''}`}
|
|
82
|
+
style={wrapperStyle}
|
|
83
|
+
onClick={handleAvatarClick}
|
|
73
84
|
>
|
|
74
|
-
{
|
|
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
|
-
{
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
42
|
-
const isTeamOrMeeting = channel
|
|
43
|
-
const isTopic =
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 ===
|
|
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 ===
|
|
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
|
|
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
|
|
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
|
-
{
|
|
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
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
|
277
|
-
const isTopic =
|
|
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
|
|
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={
|
|
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={
|
|
417
|
+
parentChannelName={finalParentChannelName}
|
|
412
418
|
isTopic={isTopic}
|
|
413
419
|
/>
|
|
414
420
|
|