@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.
Files changed (62) hide show
  1. package/dist/index.cjs +3802 -1772
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +836 -25
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +304 -1
  6. package/dist/index.d.ts +304 -1
  7. package/dist/index.mjs +3755 -1761
  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/BannedOverlay.tsx +40 -0
  14. package/src/components/ChannelActions.tsx +233 -0
  15. package/src/components/ChannelHeader.tsx +126 -5
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
  20. package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
  21. package/src/components/ChannelList.tsx +514 -47
  22. package/src/components/ClosedTopicOverlay.tsx +38 -0
  23. package/src/components/CreateChannelModal.tsx +53 -16
  24. package/src/components/EditPreview.tsx +2 -1
  25. package/src/components/ForwardMessageModal.tsx +2 -1
  26. package/src/components/MediaLightbox.tsx +314 -0
  27. package/src/components/MessageInput.tsx +21 -3
  28. package/src/components/MessageItem.tsx +10 -12
  29. package/src/components/MessageQuickReactions.tsx +3 -2
  30. package/src/components/MessageReactions.tsx +8 -3
  31. package/src/components/MessageRenderers.tsx +174 -54
  32. package/src/components/PendingOverlay.tsx +51 -0
  33. package/src/components/PinnedMessages.tsx +2 -1
  34. package/src/components/ReplyPreview.tsx +2 -1
  35. package/src/components/SkippedOverlay.tsx +36 -0
  36. package/src/components/TopicModal.tsx +189 -0
  37. package/src/components/UserPicker.tsx +1 -1
  38. package/src/components/VirtualMessageList.tsx +162 -47
  39. package/src/hooks/useBannedState.ts +27 -3
  40. package/src/hooks/useBlockedState.ts +3 -2
  41. package/src/hooks/useChannelCapabilities.ts +10 -8
  42. package/src/hooks/useChannelData.ts +1 -1
  43. package/src/hooks/useChannelListUpdates.ts +28 -5
  44. package/src/hooks/useChannelMessages.ts +2 -3
  45. package/src/hooks/useChannelRowUpdates.ts +9 -2
  46. package/src/hooks/useMessageActions.ts +23 -9
  47. package/src/hooks/useOnlineStatus.ts +71 -0
  48. package/src/hooks/useOnlineUsers.ts +115 -0
  49. package/src/hooks/usePendingState.ts +8 -3
  50. package/src/index.ts +67 -10
  51. package/src/messageTypeUtils.ts +64 -0
  52. package/src/styles/_channel-info.css +21 -0
  53. package/src/styles/_channel-list.css +276 -6
  54. package/src/styles/_media-lightbox.css +263 -0
  55. package/src/styles/_message-bubble.css +170 -13
  56. package/src/styles/_message-input.css +24 -0
  57. package/src/styles/_message-list.css +76 -6
  58. package/src/styles/_message-quick-reactions.css +5 -0
  59. package/src/styles/_message-reactions.css +7 -0
  60. package/src/styles/_topic-modal.css +154 -0
  61. package/src/styles/index.css +2 -0
  62. 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.6",
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.6",
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
 
@@ -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';