@ermis-network/ermis-chat-react 1.0.6 → 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.
- package/dist/index.cjs +3802 -1772
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +836 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.mjs +3755 -1761
- 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/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +233 -0
- package/src/components/ChannelHeader.tsx +126 -5
- package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +514 -47
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- 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 +21 -3
- package/src/components/MessageItem.tsx +10 -12
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +174 -54
- package/src/components/PendingOverlay.tsx +51 -0
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +162 -47
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -8
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +28 -5
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +9 -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 +67 -10
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +276 -6
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +170 -13
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +76 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +2 -0
- package/src/types.ts +203 -3
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.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.
|
|
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] =
|
|
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
|
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export type BannedOverlayProps = {
|
|
4
|
+
isBlocked?: boolean;
|
|
5
|
+
blockedTitle: string;
|
|
6
|
+
bannedTitle: string;
|
|
7
|
+
blockedSubtitle: string;
|
|
8
|
+
bannedSubtitle: string;
|
|
9
|
+
onUnblock?: () => void;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export const BannedOverlay: React.FC<BannedOverlayProps> = React.memo(({
|
|
13
|
+
isBlocked,
|
|
14
|
+
blockedTitle,
|
|
15
|
+
bannedTitle,
|
|
16
|
+
blockedSubtitle,
|
|
17
|
+
bannedSubtitle,
|
|
18
|
+
onUnblock,
|
|
19
|
+
}) => (
|
|
20
|
+
<div className="ermis-message-list__banned-overlay">
|
|
21
|
+
<div className="ermis-message-list__banned-overlay-icon">
|
|
22
|
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
|
23
|
+
<circle cx="12" cy="12" r="10" />
|
|
24
|
+
<line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
|
|
25
|
+
</svg>
|
|
26
|
+
</div>
|
|
27
|
+
<span className="ermis-message-list__banned-overlay-title">{isBlocked ? blockedTitle : bannedTitle}</span>
|
|
28
|
+
<span className="ermis-message-list__banned-overlay-subtitle">{isBlocked ? blockedSubtitle : bannedSubtitle}</span>
|
|
29
|
+
{isBlocked && onUnblock && (
|
|
30
|
+
<button
|
|
31
|
+
className="ermis-message-list__unblock-btn"
|
|
32
|
+
onClick={onUnblock}
|
|
33
|
+
>
|
|
34
|
+
Unblock
|
|
35
|
+
</button>
|
|
36
|
+
)}
|
|
37
|
+
</div>
|
|
38
|
+
));
|
|
39
|
+
|
|
40
|
+
BannedOverlay.displayName = 'BannedOverlay';
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import React, { useState, useCallback, useMemo } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { ChannelAction, ChannelActionLabels, ChannelActionIcons, ChannelActionsProps } from '../types';
|
|
4
|
+
import { Dropdown } from './Dropdown';
|
|
5
|
+
import { isDirectChannel, isGroupChannel, isTopicChannel } from '../channelTypeUtils';
|
|
6
|
+
import { canManageChannel, CHANNEL_ROLES } from '../channelRoleUtils';
|
|
7
|
+
|
|
8
|
+
/* ----------------------------------------------------------
|
|
9
|
+
SVG Icons for default actions
|
|
10
|
+
---------------------------------------------------------- */
|
|
11
|
+
const PinIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /></svg>);
|
|
12
|
+
const UnpinIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /><line x1="3" y1="3" x2="21" y2="21" /></svg>);
|
|
13
|
+
const BlockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" /></svg>);
|
|
14
|
+
const LeaveIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /><polyline points="16 17 21 12 16 7" /><line x1="21" y1="12" x2="9" y2="12" /></svg>);
|
|
15
|
+
const TrashIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>);
|
|
16
|
+
const LockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>);
|
|
17
|
+
const UnlockIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" /></svg>);
|
|
18
|
+
const CreateTopicIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>);
|
|
19
|
+
const EditIcon = () => (<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>);
|
|
20
|
+
const MoreIcon = () => (<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="1" /><circle cx="19" cy="12" r="1" /><circle cx="5" cy="12" r="1" /></svg>);
|
|
21
|
+
|
|
22
|
+
/* ----------------------------------------------------------
|
|
23
|
+
computeDefaultActions
|
|
24
|
+
Derives a list of ChannelAction[] based on channel type
|
|
25
|
+
and the current user's role. Currently actions only log
|
|
26
|
+
to console — real API calls will be wired later.
|
|
27
|
+
---------------------------------------------------------- */
|
|
28
|
+
export function computeDefaultActions(
|
|
29
|
+
channel: Channel,
|
|
30
|
+
currentUserId?: string,
|
|
31
|
+
options?: {
|
|
32
|
+
onAddTopic?: (channel: Channel) => void;
|
|
33
|
+
onEditTopic?: (channel: Channel) => void;
|
|
34
|
+
onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
|
|
35
|
+
isBlocked?: boolean;
|
|
36
|
+
actionLabels?: ChannelActionLabels;
|
|
37
|
+
actionIcons?: ChannelActionIcons;
|
|
38
|
+
},
|
|
39
|
+
): ChannelAction[] {
|
|
40
|
+
const actions: ChannelAction[] = [];
|
|
41
|
+
if (!currentUserId) return actions;
|
|
42
|
+
|
|
43
|
+
const isDirect = isDirectChannel(channel);
|
|
44
|
+
const isTeamOrMeeting = isGroupChannel(channel);
|
|
45
|
+
const isTopic = isTopicChannel(channel);
|
|
46
|
+
const isClosed = channel.data?.is_closed_topic === true;
|
|
47
|
+
|
|
48
|
+
const ms = channel.state?.members?.[currentUserId] || channel.state?.membership;
|
|
49
|
+
const role = ms?.channel_role;
|
|
50
|
+
const isBlocked = options?.isBlocked !== undefined ? options.isBlocked : (ms as any)?.blocked;
|
|
51
|
+
const isPinned = channel.data?.is_pinned === true;
|
|
52
|
+
|
|
53
|
+
// Pin / Unpin — available for all channel types
|
|
54
|
+
const actionLabels = options?.actionLabels;
|
|
55
|
+
|
|
56
|
+
const pinLabel = isPinned
|
|
57
|
+
? (isTopic ? (actionLabels?.unpinTopic || 'Unpin topic') : (actionLabels?.unpinChannel || 'Unpin channel'))
|
|
58
|
+
: (isTopic ? (actionLabels?.pinTopic || 'Pin topic') : (actionLabels?.pinChannel || 'Pin channel'));
|
|
59
|
+
|
|
60
|
+
const actionIcons = options?.actionIcons;
|
|
61
|
+
|
|
62
|
+
const pinIcon = isPinned
|
|
63
|
+
? (actionIcons?.UnpinIcon || <UnpinIcon />)
|
|
64
|
+
: (actionIcons?.PinIcon || <PinIcon />);
|
|
65
|
+
|
|
66
|
+
actions.push({
|
|
67
|
+
id: isPinned ? 'unpin' : 'pin',
|
|
68
|
+
label: pinLabel,
|
|
69
|
+
icon: pinIcon,
|
|
70
|
+
onClick: async (ch) => {
|
|
71
|
+
try {
|
|
72
|
+
if (isPinned) {
|
|
73
|
+
await ch.unpin();
|
|
74
|
+
} else {
|
|
75
|
+
await ch.pin();
|
|
76
|
+
}
|
|
77
|
+
} catch (e) {
|
|
78
|
+
console.error('Error toggling pin state', e);
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
if (isDirect) {
|
|
84
|
+
// Direct channel: Block / Unblock
|
|
85
|
+
actions.push({
|
|
86
|
+
id: isBlocked ? 'unblock' : 'block',
|
|
87
|
+
label: isBlocked ? (actionLabels?.unblockUser || 'Unblock user') : (actionLabels?.blockUser || 'Block user'),
|
|
88
|
+
icon: isBlocked ? (actionIcons?.UnblockIcon || <BlockIcon />) : (actionIcons?.BlockIcon || <BlockIcon />),
|
|
89
|
+
isDanger: !isBlocked,
|
|
90
|
+
onClick: async (ch) => {
|
|
91
|
+
try {
|
|
92
|
+
if (isBlocked) {
|
|
93
|
+
await ch.unblockUser();
|
|
94
|
+
} else {
|
|
95
|
+
await ch.blockUser();
|
|
96
|
+
}
|
|
97
|
+
} catch (e) {
|
|
98
|
+
console.error('Error toggling block state', e);
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
} else if (isTopic) {
|
|
103
|
+
// Topic: Edit topic (owner & moder only)
|
|
104
|
+
if (canManageChannel(role)) {
|
|
105
|
+
actions.push({
|
|
106
|
+
id: 'edit_topic',
|
|
107
|
+
label: actionLabels?.editTopic || 'Edit topic',
|
|
108
|
+
icon: actionIcons?.EditTopicIcon || <EditIcon />,
|
|
109
|
+
onClick: (ch) => {
|
|
110
|
+
options?.onEditTopic?.(ch);
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
// Topic: Close / Reopen (owner & moder only)
|
|
115
|
+
if (canManageChannel(role)) {
|
|
116
|
+
actions.push({
|
|
117
|
+
id: isClosed ? 'reopen' : 'close',
|
|
118
|
+
label: isClosed ? (actionLabels?.reopenTopic || 'Reopen topic') : (actionLabels?.closeTopic || 'Close topic'),
|
|
119
|
+
icon: isClosed ? (actionIcons?.ReopenTopicIcon || <UnlockIcon />) : (actionIcons?.CloseTopicIcon || <LockIcon />),
|
|
120
|
+
isDanger: !isClosed,
|
|
121
|
+
onClick: (ch) => {
|
|
122
|
+
options?.onToggleCloseTopic?.(ch, isClosed);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
} else if (isTeamOrMeeting) {
|
|
127
|
+
// Team channel: Create Topic (owner & moder, only if topics enabled)
|
|
128
|
+
const hasTopicsEnabled = Boolean(channel.data?.topics_enabled);
|
|
129
|
+
if (hasTopicsEnabled && canManageChannel(role) && options?.onAddTopic) {
|
|
130
|
+
actions.push({
|
|
131
|
+
id: 'create_topic',
|
|
132
|
+
label: actionLabels?.createTopic || 'Create topic',
|
|
133
|
+
icon: actionIcons?.CreateTopicIcon || <CreateTopicIcon />,
|
|
134
|
+
onClick: (ch) => { options.onAddTopic!(ch); },
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
if (role === CHANNEL_ROLES.OWNER) {
|
|
138
|
+
actions.push({
|
|
139
|
+
id: 'delete',
|
|
140
|
+
label: actionLabels?.deleteChannel || 'Delete channel',
|
|
141
|
+
icon: actionIcons?.DeleteChannelIcon || <TrashIcon />,
|
|
142
|
+
isDanger: true,
|
|
143
|
+
onClick: async (ch) => {
|
|
144
|
+
try {
|
|
145
|
+
await ch.delete();
|
|
146
|
+
} catch (e) {
|
|
147
|
+
console.error('Error deleting channel', e);
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
if (role === CHANNEL_ROLES.MODERATOR || role === CHANNEL_ROLES.MEMBER) {
|
|
153
|
+
actions.push({
|
|
154
|
+
id: 'leave',
|
|
155
|
+
label: actionLabels?.leaveChannel || 'Leave channel',
|
|
156
|
+
icon: actionIcons?.LeaveChannelIcon || <LeaveIcon />,
|
|
157
|
+
isDanger: true,
|
|
158
|
+
onClick: async (ch) => {
|
|
159
|
+
try {
|
|
160
|
+
await ch.removeMembers([currentUserId]);
|
|
161
|
+
} catch (e) {
|
|
162
|
+
console.error('Error leaving channel', e);
|
|
163
|
+
}
|
|
164
|
+
},
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return actions;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/* ----------------------------------------------------------
|
|
173
|
+
DefaultChannelActions
|
|
174
|
+
The default UI component that renders the "more" trigger
|
|
175
|
+
button and the dropdown menu. Consumer can fully replace
|
|
176
|
+
this via ChannelActionsComponent prop.
|
|
177
|
+
---------------------------------------------------------- */
|
|
178
|
+
export const DefaultChannelActions: React.FC<ChannelActionsProps> = React.memo(({ channel, actions, onClose }) => {
|
|
179
|
+
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
180
|
+
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
181
|
+
|
|
182
|
+
const handleActionsClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
|
183
|
+
e.stopPropagation();
|
|
184
|
+
setAnchorRect(e.currentTarget.getBoundingClientRect());
|
|
185
|
+
setDropdownOpen(true);
|
|
186
|
+
}, []);
|
|
187
|
+
|
|
188
|
+
const handleClose = useCallback(() => {
|
|
189
|
+
setDropdownOpen(false);
|
|
190
|
+
setAnchorRect(null);
|
|
191
|
+
onClose();
|
|
192
|
+
}, [onClose]);
|
|
193
|
+
|
|
194
|
+
if (!actions || actions.length === 0) return null;
|
|
195
|
+
|
|
196
|
+
return (
|
|
197
|
+
<>
|
|
198
|
+
<button
|
|
199
|
+
type="button"
|
|
200
|
+
className={`ermis-channel-list__actions-trigger ${dropdownOpen ? 'ermis-channel-list__actions-trigger--active' : ''}`}
|
|
201
|
+
onClick={handleActionsClick}
|
|
202
|
+
title="More actions"
|
|
203
|
+
>
|
|
204
|
+
<MoreIcon />
|
|
205
|
+
</button>
|
|
206
|
+
<Dropdown
|
|
207
|
+
isOpen={dropdownOpen}
|
|
208
|
+
anchorRect={anchorRect}
|
|
209
|
+
onClose={handleClose}
|
|
210
|
+
align="right"
|
|
211
|
+
>
|
|
212
|
+
<div className="ermis-dropdown__menu">
|
|
213
|
+
{actions.map((action) => (
|
|
214
|
+
<button
|
|
215
|
+
key={action.id}
|
|
216
|
+
className={`ermis-dropdown__item ${action.isDanger ? 'ermis-dropdown__item--danger' : ''}`}
|
|
217
|
+
onClick={(e) => {
|
|
218
|
+
e.stopPropagation();
|
|
219
|
+
handleClose();
|
|
220
|
+
action.onClick(channel, e);
|
|
221
|
+
}}
|
|
222
|
+
>
|
|
223
|
+
{action.icon}
|
|
224
|
+
<span>{action.label}</span>
|
|
225
|
+
</button>
|
|
226
|
+
))}
|
|
227
|
+
</div>
|
|
228
|
+
</Dropdown>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
DefaultChannelActions.displayName = 'DefaultChannelActions';
|