@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.
- package/dist/index.cjs +15288 -4203
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15238 -4179
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +126 -7
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- 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
|
|
78
|
-
prev.
|
|
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, {
|
|
1
|
+
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
|
|
2
2
|
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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
|
+
};
|