@ermis-network/ermis-chat-react 1.0.9 → 2.0.0

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 (99) hide show
  1. package/dist/index.cjs +15288 -4203
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15238 -4179
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +126 -7
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -74,9 +74,8 @@ export const MemberListItem = React.memo(({
74
74
  </div>
75
75
  );
76
76
  }, (prev, next) => {
77
- return prev.member?.user_id === next.member?.user_id &&
78
- prev.member?.channel_role === next.member?.channel_role &&
79
- prev.member?.banned === next.member?.banned &&
77
+ return prev.member === next.member &&
78
+ prev.AvatarComponent === next.AvatarComponent &&
80
79
  prev.canRemove === next.canRemove &&
81
80
  prev.canBan === next.canBan &&
82
81
  prev.canUnban === next.canUnban &&
@@ -1,17 +1,19 @@
1
- import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useCallback, useMemo, useEffect } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
- import { useChatClient } from '../../hooks/useChatClient';
4
- import { replaceMentionsForPreview, buildUserMap, formatRelativeDate } from '../../utils';
3
+ import { replaceMentionsForPreview, formatRelativeDate } from '../../utils';
5
4
  import { Avatar } from '../Avatar';
6
- import { Panel } from '../Panel';
7
- import type { AvatarProps, SearchResultMessage, MessageSearchPanelProps } from '../../types';
5
+ import { Panel as DefaultPanel } from '../Panel';
6
+ import { useChatComponents } from '../../context/ChatComponentsContext';
7
+ import { useChatClient } from '../../hooks/useChatClient';
8
+ import type { MessageSearchPanelProps } from '../../types';
9
+ import { useMessageSearch } from './useMessageSearch';
10
+ import { removeAccents } from '../../utils';
8
11
 
9
12
  /* ----------------------------------------------------------
10
13
  Highlight utility (Accent-insensitive)
11
14
  ---------------------------------------------------------- */
12
- const removeAccents = (str: string) => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
13
15
 
14
- const HighlightedText: React.FC<{ text: string; term: string }> = React.memo(({ text, term }) => {
16
+ export const HighlightedText: React.FC<{ text: string; term: string }> = React.memo(({ text, term }) => {
15
17
  if (!term.trim()) return <>{text}</>;
16
18
 
17
19
  const cleanTerm = removeAccents(term).toLowerCase();
@@ -58,136 +60,40 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
58
60
  AvatarComponent = Avatar,
59
61
  placeholder = 'Search messages...',
60
62
  title = 'Search Messages',
61
- emptyText = 'No messages found',
63
+ emptyText = 'No messages found.',
62
64
  loadingText = 'Searching...',
63
65
  debounceMs = 500,
64
66
  }) => {
65
67
  const { setJumpToMessageId } = useChatClient();
66
-
67
- const [query, setQuery] = useState('');
68
- const [results, setResults] = useState<SearchResultMessage[]>([]);
69
- const [loading, setLoading] = useState(false);
70
- const [hasMore, setHasMore] = useState(false);
71
- const [loadingMore, setLoadingMore] = useState(false);
72
-
73
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74
- const scrollRef = useRef<HTMLDivElement>(null);
75
- const inputRef = useRef<HTMLInputElement>(null);
76
- const offsetRef = useRef(0);
77
- const queryRef = useRef('');
78
-
79
- // Reset all state when the channel changes (or panel closes)
80
- useEffect(() => {
81
- setQuery('');
82
- setResults([]);
83
- setLoading(false);
84
- setHasMore(false);
85
- setLoadingMore(false);
86
- offsetRef.current = 0;
87
- queryRef.current = '';
88
- }, [channel?.cid, isOpen]);
68
+ const { PanelComponent } = useChatComponents();
69
+ const Panel = PanelComponent || DefaultPanel;
70
+
71
+ const {
72
+ query,
73
+ setQuery,
74
+ results,
75
+ loading,
76
+ hasMore,
77
+ loadingMore,
78
+ handleInputChange,
79
+ handleScroll,
80
+ resetSearch,
81
+ userMaps,
82
+ } = useMessageSearch({ channel, isOpen, debounceMs });
89
83
 
90
84
  // Auto-focus the input when panel opens
85
+ const inputRef = useRef<HTMLInputElement>(null);
91
86
  useEffect(() => {
92
87
  if (isOpen) {
93
88
  setTimeout(() => inputRef.current?.focus(), 300);
94
89
  }
95
90
  }, [isOpen]);
96
91
 
97
- // Debounced search
98
- const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
99
- const value = e.target.value;
100
- setQuery(value);
101
-
102
- if (debounceRef.current) clearTimeout(debounceRef.current);
103
-
104
- if (!value.trim()) {
105
- setResults([]);
106
- setLoading(false);
107
- setHasMore(false);
108
- offsetRef.current = 0;
109
- queryRef.current = '';
110
- return;
111
- }
112
-
113
- setLoading(true);
114
-
115
- debounceRef.current = setTimeout(async () => {
116
- queryRef.current = value;
117
- offsetRef.current = 0;
118
-
119
- try {
120
- const response = await channel.searchMessage(value, 0);
121
- // Only apply if this is still the latest query
122
- if (queryRef.current !== value) return;
123
-
124
- if (!response) {
125
- setResults([]);
126
- setHasMore(false);
127
- } else {
128
- setResults(response.messages || []);
129
- setHasMore((response.messages?.length || 0) >= 25);
130
- }
131
- } catch (err) {
132
- console.error('Search failed:', err);
133
- setResults([]);
134
- setHasMore(false);
135
- } finally {
136
- setLoading(false);
137
- }
138
- }, debounceMs);
139
- }, [channel, debounceMs]);
140
-
141
- // Infinite scroll: load more results
142
- const handleLoadMore = useCallback(async () => {
143
- if (loadingMore || !hasMore || !queryRef.current) return;
144
-
145
- setLoadingMore(true);
146
- const nextOffset = offsetRef.current + 25; // offset skips records, limit is 25
147
-
148
- try {
149
- const response = await channel.searchMessage(queryRef.current, nextOffset);
150
-
151
- if (!response || !response.messages?.length) {
152
- setHasMore(false);
153
- } else {
154
- offsetRef.current = nextOffset;
155
- setResults((prev) => [...prev, ...response.messages]);
156
- setHasMore(response.messages.length >= 25);
157
- }
158
- } catch (err) {
159
- console.error('Load more search results failed:', err);
160
- } finally {
161
- setLoadingMore(false);
162
- }
163
- }, [channel, hasMore, loadingMore]);
164
-
165
- // Scroll handler for infinite scroll
166
- const handleScroll = useCallback(() => {
167
- const el = scrollRef.current;
168
- if (!el) return;
169
-
170
- const threshold = 100;
171
- if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
172
- handleLoadMore();
173
- }
174
- }, [handleLoadMore]);
175
-
176
92
  // Click a result -> jump to that message
177
93
  const handleResultClick = useCallback((messageId: string) => {
178
94
  setJumpToMessageId(messageId);
179
95
  }, [setJumpToMessageId]);
180
96
 
181
- // Derived userMap for resolving mentions, with a lowercase variant for fast lookup
182
- const userMaps = useMemo(() => {
183
- const original = buildUserMap(channel.state);
184
- const lower: typeof original = {};
185
- for (const [id, name] of Object.entries(original)) {
186
- lower[id.toLowerCase()] = name;
187
- }
188
- return { original, lower };
189
- }, [channel.state]);
190
-
191
97
  return (
192
98
  <Panel isOpen={isOpen} onClose={onClose} title={title} className="ermis-search-panel">
193
99
  {/* Search Input now inside body */}
@@ -209,11 +115,7 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
209
115
  <button
210
116
  className="ermis-search-panel__input-clear"
211
117
  onClick={() => {
212
- setQuery('');
213
- setResults([]);
214
- setHasMore(false);
215
- offsetRef.current = 0;
216
- queryRef.current = '';
118
+ resetSearch();
217
119
  inputRef.current?.focus();
218
120
  }}
219
121
  aria-label="Clear"
@@ -228,7 +130,6 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
228
130
  </div>
229
131
 
230
132
  <div
231
- ref={scrollRef}
232
133
  className="ermis-search-panel__results"
233
134
  onScroll={handleScroll}
234
135
  >
@@ -28,7 +28,7 @@ export const TabEmptyState: React.FC<{ label: string }> = React.memo(({ label })
28
28
  ));
29
29
  (TabEmptyState as any).displayName = 'TabEmptyState';
30
30
 
31
- export const TabLoadingState: React.FC = React.memo(() => (
31
+ export const TabLoadingState: React.FC<{ tab?: string }> = React.memo(() => (
32
32
  <div className="ermis-channel-info__media-loading">
33
33
  <div className="ermis-channel-info__media-spinner" />
34
34
  </div>
@@ -1,10 +1,13 @@
1
1
  export * from './ChannelInfo';
2
2
  export * from './ChannelInfoTabs';
3
+ export * from './useChannelInfoTabs';
3
4
  export * from './EditChannelModal';
4
5
  export * from './MessageSearchPanel';
6
+ export * from './useMessageSearch';
5
7
  export * from './MediaGridItem';
6
8
  export * from './LinkListItem';
7
9
  export * from './FileListItem';
8
10
  export * from './MemberListItem';
9
11
  export * from './States';
10
12
  export * from './ChannelSettingsPanel';
13
+ export * from './useChannelSettings';
@@ -0,0 +1,386 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, startTransition, useRef } from 'react';
2
+ import { ROLE_WEIGHTS, MESSAGING_TABS, ALL_TABS } from './utils';
3
+ import { useBannedState } from '../../hooks/useBannedState';
4
+ import { useBlockedState } from '../../hooks/useBlockedState';
5
+ import { MediaGridItem, MediaRow } from './MediaGridItem';
6
+ import { LinkListItem } from './LinkListItem';
7
+ import { FileListItem } from './FileListItem';
8
+ import { MemberListItem } from './MemberListItem';
9
+ import { TabEmptyState, TabLoadingState } from './States';
10
+ import { useDownloadHandler } from '../../hooks/useDownloadHandler';
11
+ import type { ChannelInfoTabsProps, MediaTab, AttachmentItem, MediaLightboxItem } from '../../types';
12
+ import { isDirectChannel } from '../../channelTypeUtils';
13
+ import {
14
+ CHANNEL_ROLES,
15
+ canRemoveTargetMember,
16
+ canBanTargetMember,
17
+ canPromoteTargetMember,
18
+ canDemoteTargetMember
19
+ } from '../../channelRoleUtils';
20
+
21
+ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
22
+ const {
23
+ channel,
24
+ members,
25
+ AvatarComponent,
26
+ currentUserId,
27
+ currentUserRole,
28
+ onAddMemberClick,
29
+ onRemoveMember,
30
+ onBanMember,
31
+ onUnbanMember,
32
+ onPromoteMember,
33
+ onDemoteMember,
34
+ addMemberButtonLabel = 'Add Member',
35
+ AddMemberButtonComponent,
36
+ MemberItemComponent,
37
+ MediaItemComponent,
38
+ LinkItemComponent,
39
+ FileItemComponent,
40
+ EmptyStateComponent,
41
+ LoadingComponent,
42
+ isVisible = true,
43
+ isPreviewMode = false,
44
+ } = props;
45
+
46
+ const isMessaging = isDirectChannel(channel);
47
+ const isTopic = Boolean(channel?.data?.parent_cid);
48
+
49
+ const { isBanned } = useBannedState(channel, currentUserId);
50
+ const { isBlocked } = useBlockedState(channel, currentUserId);
51
+
52
+ const availableTabs: MediaTab[] = useMemo(() => {
53
+ let tabs = isMessaging ? MESSAGING_TABS : ALL_TABS;
54
+ if (isTopic) {
55
+ tabs = tabs.filter(t => t !== 'members');
56
+ }
57
+ return tabs;
58
+ }, [isMessaging, isTopic]);
59
+
60
+ const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
61
+ const [contentTab, setContentTab] = useState<MediaTab>(availableTabs[0]);
62
+ const [isPending, setIsPending] = useState(false);
63
+
64
+ const [attachmentsFetchedForCid, setAttachmentsFetchedForCid] = useState<string | null>(null);
65
+ const lastFetchedCidRef = useRef<string | null>(null);
66
+ const transitionRafRef = useRef<any>(null);
67
+
68
+ const handleTabChange = useCallback((tab: MediaTab) => {
69
+ if (tab === activeTab) return;
70
+
71
+ // 1. Instant UI update for the tab button
72
+ setActiveTab(tab);
73
+
74
+ if (transitionRafRef.current) clearTimeout(transitionRafRef.current);
75
+
76
+ // Check if data is already available for this channel
77
+ const hasData = tab === 'members' || attachmentsFetchedForCid === channel?.cid;
78
+
79
+ if (hasData) {
80
+ // If data exists, switch content immediately without loading state
81
+ setContentTab(tab);
82
+ setIsPending(false);
83
+ } else {
84
+ // If no data, use isPending to show Skeleton while waiting for API
85
+ setIsPending(true);
86
+ transitionRafRef.current = setTimeout(() => {
87
+ setContentTab(tab);
88
+ setIsPending(false);
89
+ setAttachmentsFetchedForCid((prev) => prev || channel?.cid || null);
90
+ }, 350);
91
+ }
92
+ }, [activeTab, channel?.cid, attachmentsFetchedForCid]);
93
+
94
+ // Reset tab when user switches channels
95
+ useEffect(() => {
96
+ if (transitionRafRef.current) clearTimeout(transitionRafRef.current);
97
+ setActiveTab(availableTabs[0]);
98
+ setContentTab(availableTabs[0]);
99
+ setIsPending(false);
100
+ setAttachmentsFetchedForCid(null);
101
+ setAllAttachments([]);
102
+ lastFetchedCidRef.current = null;
103
+ // eslint-disable-next-line react-hooks/exhaustive-deps
104
+ }, [channel?.cid, availableTabs]);
105
+
106
+ // Auto-trigger fetch for channels where default tab needs attachment data
107
+ useEffect(() => {
108
+ if (!isVisible) return;
109
+ if (availableTabs[0] === 'members') return;
110
+ const rafId = requestAnimationFrame(() => {
111
+ setAttachmentsFetchedForCid(channel?.cid || null);
112
+ });
113
+ return () => cancelAnimationFrame(rafId);
114
+ // eslint-disable-next-line react-hooks/exhaustive-deps
115
+ }, [channel?.cid, availableTabs, isVisible]);
116
+
117
+ // Resolve sub-components with defaults
118
+ const MemberItem = MemberItemComponent || MemberListItem;
119
+ const MediaItem = MediaItemComponent || MediaGridItem;
120
+ const LinkItem = LinkItemComponent || LinkListItem;
121
+ const FileItem = FileItemComponent || FileListItem;
122
+ const EmptyState = EmptyStateComponent || TabEmptyState;
123
+ const Loading = LoadingComponent || TabLoadingState;
124
+
125
+ const [allAttachments, setAllAttachments] = useState<AttachmentItem[]>([]);
126
+ const [loading, setLoading] = useState(true);
127
+ const [refreshAttachmentsCount, setRefreshAttachmentsCount] = useState(0);
128
+
129
+ const forceRefreshAttachments = useCallback(() => {
130
+ lastFetchedCidRef.current = null;
131
+ setRefreshAttachmentsCount(c => c + 1);
132
+ }, []);
133
+
134
+ const sortedMembers = useMemo(() => {
135
+ return [...members].sort((a, b) => {
136
+ const aWeight = ROLE_WEIGHTS[a.channel_role || CHANNEL_ROLES.MEMBER] || 0;
137
+ const bWeight = ROLE_WEIGHTS[b.channel_role || CHANNEL_ROLES.MEMBER] || 0;
138
+ return bWeight - aWeight;
139
+ });
140
+ }, [members]);
141
+
142
+ // Categorize attachments by type
143
+ const mediaItems = useMemo(() =>
144
+ allAttachments.filter(a => a.attachment_type === 'image' || a.attachment_type === 'video'),
145
+ [allAttachments]
146
+ );
147
+
148
+ const linkItems = useMemo(() =>
149
+ allAttachments.filter(a => a.attachment_type === 'linkPreview'),
150
+ [allAttachments]
151
+ );
152
+
153
+ const fileItems = useMemo(() =>
154
+ allAttachments.filter(a => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
155
+ [allAttachments]
156
+ );
157
+
158
+ useEffect(() => {
159
+ if (!isVisible) return;
160
+ if (!attachmentsFetchedForCid || attachmentsFetchedForCid !== channel?.cid) {
161
+ setLoading(false);
162
+ return;
163
+ }
164
+ if (lastFetchedCidRef.current === channel?.cid) return;
165
+
166
+ let active = true;
167
+
168
+ if (isBanned || isBlocked || isPreviewMode) {
169
+ setAllAttachments([]);
170
+ setLoading(false);
171
+ return;
172
+ }
173
+
174
+ const fetchMedia = async () => {
175
+ setLoading(true);
176
+ try {
177
+ const response: any = await channel.queryAttachmentMessages();
178
+ if (active) {
179
+ const items = response?.attachments || [];
180
+ startTransition(() => {
181
+ setAllAttachments(items);
182
+ });
183
+ lastFetchedCidRef.current = channel?.cid || null;
184
+ }
185
+ } catch (err) {
186
+ console.error("Failed to query media for channel info", err);
187
+ if (active) setAllAttachments([]);
188
+ } finally {
189
+ if (active) setLoading(false);
190
+ }
191
+ };
192
+
193
+ fetchMedia();
194
+ return () => { active = false; };
195
+ }, [channel, isBanned, isBlocked, isPreviewMode, attachmentsFetchedForCid, isVisible, refreshAttachmentsCount]);
196
+
197
+ // Listen to realtime events to automatically refresh attachments
198
+ useEffect(() => {
199
+ if (!channel || !isVisible) return;
200
+
201
+ const handleEvent = (event: any) => {
202
+ if (event.message?.attachments && event.message.attachments.length > 0) {
203
+ forceRefreshAttachments();
204
+ }
205
+ };
206
+
207
+ channel.on('message.new', handleEvent);
208
+ channel.on('message.deleted', handleEvent);
209
+ channel.on('message.updated', handleEvent);
210
+
211
+ return () => {
212
+ channel.off('message.new', handleEvent);
213
+ channel.off('message.deleted', handleEvent);
214
+ channel.off('message.updated', handleEvent);
215
+ };
216
+ }, [channel, isVisible, forceRefreshAttachments]);
217
+
218
+ const { downloadFile } = useDownloadHandler();
219
+
220
+ const handleDownloadFile = useCallback(async (url: string, filename?: string) => {
221
+ await downloadFile(url, filename);
222
+ }, [downloadFile]);
223
+
224
+ // Lightbox state for media tab
225
+ const [lightboxOpen, setLightboxOpen] = useState(false);
226
+ const [lightboxIndex, setLightboxIndex] = useState(0);
227
+
228
+ const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
229
+ return mediaItems.map(item => ({
230
+ type: (item.attachment_type === 'video' ? 'video' : 'image') as 'image' | 'video',
231
+ src: item.url,
232
+ alt: item.file_name,
233
+ posterSrc: item.thumb_url || undefined,
234
+ }));
235
+ }, [mediaItems]);
236
+
237
+ const handleMediaClick = useCallback((url: string) => {
238
+ const idx = mediaItems.findIndex(item => item.url === url);
239
+ if (idx >= 0) {
240
+ setLightboxIndex(idx);
241
+ setLightboxOpen(true);
242
+ }
243
+ }, [mediaItems]);
244
+
245
+ const closeLightbox = useCallback(() => {
246
+ setLightboxOpen(false);
247
+ }, []);
248
+
249
+ // Group media into rows of 3 for grid layout inside VList
250
+ const mediaRows = useMemo(() => {
251
+ const rows: AttachmentItem[][] = [];
252
+ for (let i = 0; i < mediaItems.length; i += 3) {
253
+ rows.push(mediaItems.slice(i, i + 3));
254
+ }
255
+ return rows;
256
+ }, [mediaItems]);
257
+
258
+ // Build VList data array based on contentTab (deferred)
259
+ const vlistData = useMemo(() => {
260
+ switch (contentTab) {
261
+ case 'members': {
262
+ const items: any[] = [];
263
+ if (onAddMemberClick) {
264
+ items.push({ type: 'add-member' });
265
+ }
266
+ sortedMembers.forEach(member => {
267
+ items.push({ type: 'member', data: member });
268
+ });
269
+ return items;
270
+ }
271
+ case 'media':
272
+ return mediaRows.map(row => ({ type: 'media-row', data: row }));
273
+ case 'links':
274
+ return linkItems.map(item => ({ type: 'link', data: item }));
275
+ case 'files':
276
+ return fileItems.map(item => ({ type: 'file', data: item }));
277
+ default:
278
+ return [];
279
+ }
280
+ }, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick]);
281
+
282
+ // Render function for VList items
283
+ const renderVlistItem = useCallback((item: any, index: number) => {
284
+ switch (item.type) {
285
+ case 'add-member':
286
+ if (AddMemberButtonComponent) {
287
+ return (
288
+ <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
289
+ <AddMemberButtonComponent onClick={onAddMemberClick!} label={addMemberButtonLabel} />
290
+ </div>
291
+ );
292
+ }
293
+ return (
294
+ <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
295
+ <button className="ermis-channel-info__add-member-btn" onClick={onAddMemberClick}>
296
+ <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
297
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
298
+ <circle cx="8.5" cy="7" r="4"></circle>
299
+ <line x1="20" y1="8" x2="20" y2="14"></line>
300
+ <line x1="23" y1="11" x2="17" y2="11"></line>
301
+ </svg>
302
+ {addMemberButtonLabel}
303
+ </button>
304
+ </div>
305
+ );
306
+ case 'member': {
307
+ const member = item.data;
308
+ const role = member.channel_role || CHANNEL_ROLES.MEMBER;
309
+ const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
310
+ const canRemove = Boolean(isTargetRemovable && member.user_id !== currentUserId);
311
+ const canBan = Boolean(canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && !member.banned);
312
+ const canUnban = Boolean(canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && member.banned);
313
+ const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
314
+ const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
315
+
316
+ return (
317
+ <MemberItem
318
+ key={member?.user_id || index}
319
+ member={member}
320
+ AvatarComponent={AvatarComponent}
321
+ onRemove={onRemoveMember}
322
+ canRemove={canRemove}
323
+ onBan={onBanMember}
324
+ canBan={canBan}
325
+ onUnban={onUnbanMember}
326
+ canUnban={canUnban}
327
+ onPromote={onPromoteMember}
328
+ canPromote={canPromote}
329
+ onDemote={onDemoteMember}
330
+ canDemote={canDemote}
331
+ />
332
+ );
333
+ }
334
+ case 'media-row':
335
+ return (
336
+ <MediaRow
337
+ key={item.data[0]?.id || index}
338
+ row={item.data}
339
+ onClick={handleMediaClick}
340
+ MediaItemComponent={MediaItem}
341
+ />
342
+ );
343
+ case 'link':
344
+ return <LinkItem key={item.data.id || index} item={item.data} />;
345
+ case 'file':
346
+ const fileItem = item.data as AttachmentItem;
347
+ return (
348
+ <FileItem
349
+ key={fileItem.id || index}
350
+ item={fileItem}
351
+ onClick={(url: string) => handleDownloadFile(url, fileItem.file_name)}
352
+ />
353
+ );
354
+ default:
355
+ return null;
356
+ }
357
+ }, [
358
+ onAddMemberClick, AddMemberButtonComponent, addMemberButtonLabel,
359
+ currentUserRole, currentUserId, AvatarComponent, onRemoveMember, onBanMember, onUnbanMember, onPromoteMember, onDemoteMember,
360
+ handleMediaClick, MediaItem, handleDownloadFile,
361
+ MemberItem, LinkItem, FileItem
362
+ ]);
363
+
364
+ const isTabEmpty = vlistData.length === 0 && !(loading && contentTab !== 'members');
365
+ const emptyLabel = contentTab === 'members' ? 'members' : contentTab;
366
+
367
+ return {
368
+ availableTabs,
369
+ activeTab,
370
+ contentTab,
371
+ handleTabChange,
372
+ isPending,
373
+ loading,
374
+ isTabEmpty,
375
+ emptyLabel,
376
+ vlistData,
377
+ renderVlistItem,
378
+ lightboxItems,
379
+ lightboxOpen,
380
+ lightboxIndex,
381
+ handleMediaClick,
382
+ closeLightbox,
383
+ EmptyState,
384
+ Loading,
385
+ };
386
+ };