@ermis-network/ermis-chat-react 1.0.9 → 2.0.1
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/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- 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 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- 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 +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- 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/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -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 +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -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/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
|
2
|
+
import { VList as _VList, type VListHandle } from 'virtua';
|
|
3
|
+
const VList = _VList as any;
|
|
4
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
6
|
+
import { useTopicGroupUpdates } from '../hooks/useTopicGroupUpdates';
|
|
7
|
+
import { ChannelRow } from './ChannelList';
|
|
8
|
+
import { ChannelItem } from './ChannelList';
|
|
9
|
+
import { Avatar } from './Avatar';
|
|
10
|
+
import { DefaultPinnedIcon } from './ChannelList';
|
|
11
|
+
import { TopicModal } from './TopicModal';
|
|
12
|
+
import { isPendingMember, isSkippedMember } from '../channelRoleUtils';
|
|
13
|
+
import type { TopicListProps } from '../types';
|
|
14
|
+
|
|
15
|
+
/* ----------------------------------------------------------
|
|
16
|
+
Default avatars for general and topic items
|
|
17
|
+
---------------------------------------------------------- */
|
|
18
|
+
const DefaultGeneralAvatar = React.memo(() => (
|
|
19
|
+
<div className="ermis-channel-list__topic-hashtag">#</div>
|
|
20
|
+
));
|
|
21
|
+
DefaultGeneralAvatar.displayName = 'DefaultGeneralAvatar';
|
|
22
|
+
|
|
23
|
+
const DefaultTopicEmojiAvatar = React.memo(({ image }: { image?: string | null }) => {
|
|
24
|
+
let emoji = '💬';
|
|
25
|
+
if (image && typeof image === 'string' && image.startsWith('emoji://')) {
|
|
26
|
+
emoji = image.replace('emoji://', '');
|
|
27
|
+
}
|
|
28
|
+
return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
|
|
29
|
+
});
|
|
30
|
+
DefaultTopicEmojiAvatar.displayName = 'DefaultTopicEmojiAvatar';
|
|
31
|
+
|
|
32
|
+
/* ----------------------------------------------------------
|
|
33
|
+
TopicList – headless virtualized list of topics
|
|
34
|
+
---------------------------------------------------------- */
|
|
35
|
+
export const TopicList: React.FC<TopicListProps> = React.memo(({
|
|
36
|
+
channel,
|
|
37
|
+
ChannelItemComponent = ChannelItem,
|
|
38
|
+
AvatarComponent = Avatar,
|
|
39
|
+
GeneralAvatarComponent,
|
|
40
|
+
TopicAvatarComponent,
|
|
41
|
+
generalTopicLabel = 'general',
|
|
42
|
+
PinnedIconComponent = DefaultPinnedIcon,
|
|
43
|
+
ChannelActionsComponent,
|
|
44
|
+
onSelectTopic,
|
|
45
|
+
onEditTopic,
|
|
46
|
+
onToggleCloseTopic,
|
|
47
|
+
onDeleteTopic,
|
|
48
|
+
hiddenActions,
|
|
49
|
+
actionLabels,
|
|
50
|
+
actionIcons,
|
|
51
|
+
closedTopicIcon,
|
|
52
|
+
pendingBadgeLabel,
|
|
53
|
+
blockedBadgeLabel,
|
|
54
|
+
scrollToTopOnOwnMessage = true,
|
|
55
|
+
deletedMessageLabel,
|
|
56
|
+
stickerMessageLabel,
|
|
57
|
+
photoMessageLabel,
|
|
58
|
+
videoMessageLabel,
|
|
59
|
+
voiceRecordingMessageLabel,
|
|
60
|
+
fileMessageLabel,
|
|
61
|
+
encryptedMessageLabel,
|
|
62
|
+
encryptedMessageUnavailableLabel,
|
|
63
|
+
systemMessageTranslations,
|
|
64
|
+
signalMessageTranslations,
|
|
65
|
+
}) => {
|
|
66
|
+
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
67
|
+
const currentUserId = client.userID;
|
|
68
|
+
const { topics } = useTopicGroupUpdates(channel, currentUserId);
|
|
69
|
+
|
|
70
|
+
// Ref for imperative scroll control on the virtualized list
|
|
71
|
+
const vlistRef = useRef<VListHandle>(null);
|
|
72
|
+
|
|
73
|
+
// Auto-scroll to top when the current user sends a message in any topic
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!scrollToTopOnOwnMessage || !currentUserId) return;
|
|
76
|
+
|
|
77
|
+
const subs: { unsubscribe: () => void }[] = [];
|
|
78
|
+
|
|
79
|
+
const handleNewMessage = (event: { user?: { id?: string } }) => {
|
|
80
|
+
if (event.user?.id === currentUserId) {
|
|
81
|
+
setTimeout(() => vlistRef.current?.scrollToIndex(0), 0);
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
// Listen on parent channel
|
|
86
|
+
subs.push(channel.on('message.new', handleNewMessage));
|
|
87
|
+
|
|
88
|
+
// Listen on all sub-topics
|
|
89
|
+
const currentTopics = channel.state?.topics || [];
|
|
90
|
+
currentTopics.forEach((t: Channel) => {
|
|
91
|
+
subs.push(t.on('message.new', handleNewMessage));
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return () => {
|
|
95
|
+
subs.forEach((s) => s.unsubscribe());
|
|
96
|
+
};
|
|
97
|
+
}, [channel, channel.state?.topics, currentUserId, scrollToTopOnOwnMessage]);
|
|
98
|
+
|
|
99
|
+
// Default edit topic handler: open built-in TopicModal when no custom handler is provided
|
|
100
|
+
const [editingTopic, setEditingTopic] = useState<Channel | null>(null);
|
|
101
|
+
|
|
102
|
+
const handleEditTopic = useCallback((topic: Channel) => {
|
|
103
|
+
if (onEditTopic) {
|
|
104
|
+
onEditTopic(topic);
|
|
105
|
+
} else {
|
|
106
|
+
setEditingTopic(topic);
|
|
107
|
+
}
|
|
108
|
+
}, [onEditTopic]);
|
|
109
|
+
|
|
110
|
+
// General channel proxy — display parent channel as the general topic
|
|
111
|
+
const generalProxy = useMemo(() => {
|
|
112
|
+
return new Proxy(channel, {
|
|
113
|
+
get(target, prop, receiver) {
|
|
114
|
+
if (prop === 'data') {
|
|
115
|
+
return { ...target.data, name: generalTopicLabel, is_pinned: false };
|
|
116
|
+
}
|
|
117
|
+
const value = Reflect.get(target, prop, receiver);
|
|
118
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
}, [channel, generalTopicLabel]);
|
|
122
|
+
|
|
123
|
+
const markChannelRead = useCallback((ch: Channel) => {
|
|
124
|
+
const client = ch.getClient();
|
|
125
|
+
const activeCh = client.activeChannels[ch.cid] || ch;
|
|
126
|
+
const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
|
|
127
|
+
const chState = activeCh.state as unknown as Record<string, unknown> | undefined;
|
|
128
|
+
const isBannedInChannel = Boolean(ms?.banned);
|
|
129
|
+
const isPending = isPendingMember(ms?.channel_role as string);
|
|
130
|
+
const isSkipped = isSkippedMember(ms?.channel_role as string);
|
|
131
|
+
|
|
132
|
+
if (!isBannedInChannel && !isPending && !isSkipped) {
|
|
133
|
+
if ((chState?.unreadCount as number) > 0) {
|
|
134
|
+
activeCh.markRead().catch(() => { });
|
|
135
|
+
if (chState) chState.unreadCount = 0;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Always clear the stale channel just in case to fix UI ghost badges
|
|
139
|
+
if (ch.state && (ch.state as any).unreadCount > 0) {
|
|
140
|
+
(ch.state as any).unreadCount = 0;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handleSelectGeneral = useCallback(() => {
|
|
146
|
+
if (onSelectTopic) {
|
|
147
|
+
onSelectTopic(channel);
|
|
148
|
+
} else {
|
|
149
|
+
setActiveChannel(channel);
|
|
150
|
+
}
|
|
151
|
+
markChannelRead(channel);
|
|
152
|
+
}, [channel, onSelectTopic, setActiveChannel, markChannelRead]);
|
|
153
|
+
|
|
154
|
+
const handleSelectTopic = useCallback((topic: Channel) => {
|
|
155
|
+
if (onSelectTopic) {
|
|
156
|
+
onSelectTopic(topic);
|
|
157
|
+
} else {
|
|
158
|
+
setActiveChannel(topic);
|
|
159
|
+
}
|
|
160
|
+
markChannelRead(topic);
|
|
161
|
+
}, [onSelectTopic, setActiveChannel, markChannelRead]);
|
|
162
|
+
|
|
163
|
+
/** Null actions component for the general item */
|
|
164
|
+
const NoActions = useCallback(() => null, []);
|
|
165
|
+
|
|
166
|
+
return (
|
|
167
|
+
<>
|
|
168
|
+
<VList ref={vlistRef} style={{ height: '100%' }}>
|
|
169
|
+
{/* General (parent channel) — no actions menu */}
|
|
170
|
+
<ChannelRow
|
|
171
|
+
channel={generalProxy as Channel}
|
|
172
|
+
isActive={activeChannel?.cid === channel.cid}
|
|
173
|
+
handleSelect={handleSelectGeneral}
|
|
174
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
175
|
+
AvatarComponent={GeneralAvatarComponent || DefaultGeneralAvatar as any}
|
|
176
|
+
currentUserId={currentUserId}
|
|
177
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
178
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
179
|
+
ChannelActionsComponent={NoActions}
|
|
180
|
+
hiddenActions={hiddenActions}
|
|
181
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
182
|
+
stickerMessageLabel={stickerMessageLabel}
|
|
183
|
+
photoMessageLabel={photoMessageLabel}
|
|
184
|
+
videoMessageLabel={videoMessageLabel}
|
|
185
|
+
voiceRecordingMessageLabel={voiceRecordingMessageLabel}
|
|
186
|
+
fileMessageLabel={fileMessageLabel}
|
|
187
|
+
encryptedMessageLabel={encryptedMessageLabel}
|
|
188
|
+
encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
|
|
189
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
190
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
191
|
+
/>
|
|
192
|
+
{/* Sub-topics — with full data (last msg, unread, timestamp, pin icon) */}
|
|
193
|
+
{topics.map((topic: Channel) => (
|
|
194
|
+
<ChannelRow
|
|
195
|
+
key={topic.cid}
|
|
196
|
+
channel={topic}
|
|
197
|
+
isActive={activeChannel?.cid === topic.cid}
|
|
198
|
+
handleSelect={handleSelectTopic}
|
|
199
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
200
|
+
AvatarComponent={TopicAvatarComponent || DefaultTopicEmojiAvatar as any}
|
|
201
|
+
currentUserId={currentUserId}
|
|
202
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
203
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
204
|
+
closedTopicIcon={closedTopicIcon}
|
|
205
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
206
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
207
|
+
onEditTopic={handleEditTopic}
|
|
208
|
+
onToggleCloseTopic={onToggleCloseTopic}
|
|
209
|
+
onDeleteTopic={onDeleteTopic}
|
|
210
|
+
hiddenActions={hiddenActions}
|
|
211
|
+
actionLabels={actionLabels}
|
|
212
|
+
actionIcons={actionIcons}
|
|
213
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
214
|
+
stickerMessageLabel={stickerMessageLabel}
|
|
215
|
+
photoMessageLabel={photoMessageLabel}
|
|
216
|
+
videoMessageLabel={videoMessageLabel}
|
|
217
|
+
voiceRecordingMessageLabel={voiceRecordingMessageLabel}
|
|
218
|
+
fileMessageLabel={fileMessageLabel}
|
|
219
|
+
encryptedMessageLabel={encryptedMessageLabel}
|
|
220
|
+
encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
|
|
221
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
222
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
223
|
+
/>
|
|
224
|
+
))}
|
|
225
|
+
</VList>
|
|
226
|
+
{editingTopic && (
|
|
227
|
+
<TopicModal
|
|
228
|
+
isOpen={true}
|
|
229
|
+
onClose={() => setEditingTopic(null)}
|
|
230
|
+
topic={editingTopic}
|
|
231
|
+
/>
|
|
232
|
+
)}
|
|
233
|
+
</>
|
|
234
|
+
);
|
|
235
|
+
});
|
|
236
|
+
TopicList.displayName = 'TopicList';
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useState, useCallback } from 'react';
|
|
2
2
|
import type { CreateTopicData, EditTopicData } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
-
import { Modal } from './Modal';
|
|
3
|
+
import { Modal as DefaultModal } from './Modal';
|
|
4
4
|
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import { useChatComponents } from '../context/ChatComponentsContext';
|
|
5
6
|
import type { TopicModalProps } from '../types';
|
|
6
7
|
|
|
7
8
|
const DEFAULT_TOPIC_ICONS = ['💬', '🔥', '🚀', '⭐', '💡', '🎉', '📌', '📁', '🎨', '💻', '📈', '🤝'];
|
|
@@ -24,6 +25,8 @@ export const TopicModal: React.FC<TopicModalProps> = React.memo(({
|
|
|
24
25
|
savingButtonLabel = topic ? 'Saving...' : 'Creating...',
|
|
25
26
|
}) => {
|
|
26
27
|
const { activeChannel, client } = useChatClient();
|
|
28
|
+
const { ModalComponent } = useChatComponents();
|
|
29
|
+
const Modal = ModalComponent || DefaultModal;
|
|
27
30
|
|
|
28
31
|
const originalName = (topic?.data?.name as string) || '';
|
|
29
32
|
const originalImage = (topic?.data?.image as string) || '';
|
|
@@ -2,7 +2,9 @@ import React from 'react';
|
|
|
2
2
|
import { useTypingIndicator, type TypingUser } from '../hooks/useTypingIndicator';
|
|
3
3
|
|
|
4
4
|
export type TypingIndicatorProps = {
|
|
5
|
-
/** Custom render function for the typing text */
|
|
5
|
+
/** Custom render function for the typing text (I18n) */
|
|
6
|
+
typingIndicatorLabel?: (users: TypingUser[]) => string;
|
|
7
|
+
/** Custom render function for the typing text (JSX) */
|
|
6
8
|
renderText?: (users: TypingUser[]) => React.ReactNode;
|
|
7
9
|
};
|
|
8
10
|
|
|
@@ -10,26 +12,33 @@ export type TypingIndicatorProps = {
|
|
|
10
12
|
* Displays a "X is typing..." indicator below the message list.
|
|
11
13
|
* Automatically subscribes to typing events via the useTypingIndicator hook.
|
|
12
14
|
*/
|
|
13
|
-
export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ renderText }) => {
|
|
15
|
+
export const TypingIndicator: React.FC<TypingIndicatorProps> = React.memo(({ typingIndicatorLabel, renderText }) => {
|
|
14
16
|
const { typingUsers } = useTypingIndicator();
|
|
15
17
|
|
|
16
18
|
const isActive = typingUsers.length > 0;
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
let text: React.ReactNode = null;
|
|
21
|
+
if (isActive) {
|
|
22
|
+
if (renderText) {
|
|
23
|
+
text = renderText(typingUsers);
|
|
24
|
+
} else if (typingIndicatorLabel) {
|
|
25
|
+
text = typingIndicatorLabel(typingUsers);
|
|
26
|
+
} else {
|
|
27
|
+
text = formatTypingText(typingUsers);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
21
30
|
|
|
22
31
|
return (
|
|
23
|
-
<div className={`ermis-typing-indicator
|
|
32
|
+
<div className={`ermis-typing-indicator-wrapper`}>
|
|
24
33
|
{isActive && (
|
|
25
|
-
|
|
34
|
+
<div className="ermis-typing-indicator ermis-typing-indicator--active">
|
|
26
35
|
<div className="ermis-typing-indicator__dots">
|
|
27
36
|
<span className="ermis-typing-indicator__dot" />
|
|
28
37
|
<span className="ermis-typing-indicator__dot" />
|
|
29
38
|
<span className="ermis-typing-indicator__dot" />
|
|
30
39
|
</div>
|
|
31
40
|
<span className="ermis-typing-indicator__text">{text}</span>
|
|
32
|
-
|
|
41
|
+
</div>
|
|
33
42
|
)}
|
|
34
43
|
</div>
|
|
35
44
|
);
|
|
@@ -1,13 +1,16 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback, useRef, useTransition } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { Avatar } from './Avatar';
|
|
4
|
-
import { VList, type VListHandle } from 'virtua';
|
|
4
|
+
import { VList as _VList, type VListHandle } from 'virtua';
|
|
5
|
+
const VList = _VList as any;
|
|
5
6
|
import type {
|
|
6
7
|
UserPickerProps,
|
|
7
8
|
UserPickerItemProps,
|
|
8
9
|
UserPickerSelectedBoxProps,
|
|
9
10
|
UserPickerUser,
|
|
10
11
|
} from '../types';
|
|
12
|
+
import { isFriendChannel } from '../channelRoleUtils';
|
|
13
|
+
import { removeAccents } from '../utils';
|
|
11
14
|
|
|
12
15
|
/* ---------- Constants ---------- */
|
|
13
16
|
const DEFAULT_PAGE_SIZE = 30;
|
|
@@ -113,6 +116,9 @@ DefaultSelectedBox.displayName = 'DefaultSelectedBox';
|
|
|
113
116
|
UserPicker Component
|
|
114
117
|
========================================================== */
|
|
115
118
|
|
|
119
|
+
// Global cache to persist users across UserPicker unmounts/remounts (e.g. during tab switch)
|
|
120
|
+
const globalUsersCache: Record<string, { users: UserPickerUser[], page: number, hasMore: boolean }> = {};
|
|
121
|
+
|
|
116
122
|
export const UserPicker: React.FC<UserPickerProps> = ({
|
|
117
123
|
mode,
|
|
118
124
|
onSelectionChange,
|
|
@@ -128,6 +134,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
128
134
|
emptyText = 'No users found.',
|
|
129
135
|
loadingMoreText = 'Loading more...',
|
|
130
136
|
selectedEmptyLabel,
|
|
137
|
+
friendsOnly,
|
|
131
138
|
}) => {
|
|
132
139
|
const { client } = useChatClient();
|
|
133
140
|
const currentUserId = client?.userID;
|
|
@@ -176,13 +183,64 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
176
183
|
let active = true;
|
|
177
184
|
const fetchUsers = async () => {
|
|
178
185
|
if (!client) return;
|
|
186
|
+
|
|
187
|
+
// For friendsOnly mode, always read fresh data from activeChannels.
|
|
188
|
+
// Do NOT use globalUsersCache here — the friend list can change at any
|
|
189
|
+
// time (e.g. a new friend request was accepted) and caching would
|
|
190
|
+
// return stale results, hiding the newly added friend.
|
|
191
|
+
if (friendsOnly) {
|
|
192
|
+
const friends: UserPickerUser[] = [];
|
|
193
|
+
const seenIds = new Set<string>();
|
|
194
|
+
|
|
195
|
+
for (const channel of Object.values(client.activeChannels)) {
|
|
196
|
+
const members = channel.state?.members;
|
|
197
|
+
if (!members) continue;
|
|
198
|
+
|
|
199
|
+
for (const [memberId, member] of Object.entries(members)) {
|
|
200
|
+
if (memberId === client.userID) continue;
|
|
201
|
+
|
|
202
|
+
if (isFriendChannel(channel, memberId, client.userID as string) && !seenIds.has(memberId)) {
|
|
203
|
+
if (member.user) {
|
|
204
|
+
friends.push(member.user as UserPickerUser);
|
|
205
|
+
seenIds.add(memberId);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (active) {
|
|
212
|
+
setAllUsers(friends);
|
|
213
|
+
setHasMore(false);
|
|
214
|
+
setPage(1);
|
|
215
|
+
setLoading(false);
|
|
216
|
+
}
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
|
|
221
|
+
|
|
222
|
+
if (globalUsersCache[cacheKey] && globalUsersCache[cacheKey].users.length > 0) {
|
|
223
|
+
const cached = globalUsersCache[cacheKey];
|
|
224
|
+
setAllUsers(cached.users);
|
|
225
|
+
setHasMore(cached.hasMore);
|
|
226
|
+
setPage(cached.page);
|
|
227
|
+
setLoading(false);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
|
|
179
231
|
try {
|
|
180
232
|
setLoading(true);
|
|
181
|
-
const response = await client.queryUsers(
|
|
233
|
+
const response = await client.queryUsers(pageSize, 1);
|
|
182
234
|
if (active && response.data) {
|
|
183
235
|
setAllUsers(response.data);
|
|
184
236
|
setHasMore(response.data.length >= pageSize);
|
|
185
237
|
setPage(1);
|
|
238
|
+
|
|
239
|
+
globalUsersCache[cacheKey] = {
|
|
240
|
+
users: response.data,
|
|
241
|
+
page: 1,
|
|
242
|
+
hasMore: response.data.length >= pageSize
|
|
243
|
+
};
|
|
186
244
|
}
|
|
187
245
|
} catch (err) {
|
|
188
246
|
console.error('[UserPicker] Error fetching users:', err);
|
|
@@ -192,7 +250,7 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
192
250
|
};
|
|
193
251
|
fetchUsers();
|
|
194
252
|
return () => { active = false; };
|
|
195
|
-
}, [client, pageSize]);
|
|
253
|
+
}, [client, pageSize, friendsOnly]);
|
|
196
254
|
|
|
197
255
|
/* ---------- 2. Load more (infinite scroll) ---------- */
|
|
198
256
|
const loadMore = useCallback(async () => {
|
|
@@ -200,12 +258,23 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
200
258
|
const nextPage = page + 1;
|
|
201
259
|
setLoadingMore(true);
|
|
202
260
|
try {
|
|
203
|
-
const response = await client.queryUsers(
|
|
261
|
+
const response = await client.queryUsers(pageSize, nextPage);
|
|
204
262
|
if (response.data) {
|
|
205
263
|
setAllUsers(prev => {
|
|
206
264
|
const existingIds = new Set(prev.map(u => u.id));
|
|
207
265
|
const newUsers = response.data.filter((u: UserPickerUser) => !existingIds.has(u.id));
|
|
208
|
-
|
|
266
|
+
const combined = [...prev, ...newUsers];
|
|
267
|
+
|
|
268
|
+
if (client) {
|
|
269
|
+
const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
|
|
270
|
+
globalUsersCache[cacheKey] = {
|
|
271
|
+
users: combined,
|
|
272
|
+
page: nextPage,
|
|
273
|
+
hasMore: response.data.length >= pageSize
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
return combined;
|
|
209
278
|
});
|
|
210
279
|
setHasMore(response.data.length >= pageSize);
|
|
211
280
|
setPage(nextPage);
|
|
@@ -219,19 +288,24 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
219
288
|
|
|
220
289
|
/* ---------- 3. Local filter ---------- */
|
|
221
290
|
const localFilteredUsers = useMemo(() => {
|
|
222
|
-
const term = search.toLowerCase().trim();
|
|
291
|
+
const term = removeAccents(search.toLowerCase().trim());
|
|
223
292
|
if (!term) return allUsers;
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
293
|
+
const result: UserPickerUser[] = [];
|
|
294
|
+
for (const u of allUsers) {
|
|
295
|
+
const name = removeAccents((u.name || '').toLowerCase());
|
|
296
|
+
const email = removeAccents((u.email || '').toLowerCase());
|
|
297
|
+
const phone = removeAccents((u.phone || '').toLowerCase());
|
|
298
|
+
if (name.startsWith(term) || email.startsWith(term) || phone.startsWith(term)) {
|
|
299
|
+
result.push(u);
|
|
300
|
+
if (result.length >= 100) break; // optimize for large room
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return result;
|
|
230
304
|
}, [search, allUsers]);
|
|
231
305
|
|
|
232
306
|
/* ---------- 4. Remote search fallback ---------- */
|
|
233
307
|
useEffect(() => {
|
|
234
|
-
if (!search.trim() || localFilteredUsers.length > 0) {
|
|
308
|
+
if (!search.trim() || localFilteredUsers.length > 0 || friendsOnly) {
|
|
235
309
|
setRemoteUsers([]);
|
|
236
310
|
setIsSearching(false);
|
|
237
311
|
return;
|
|
@@ -259,9 +333,13 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
259
333
|
}, [search, localFilteredUsers.length, client]);
|
|
260
334
|
|
|
261
335
|
/* ---------- 5. Derived display list ---------- */
|
|
262
|
-
const usersToDisplay = (
|
|
263
|
-
|
|
264
|
-
|
|
336
|
+
const usersToDisplay = useMemo(() => {
|
|
337
|
+
const list = (search.trim() && localFilteredUsers.length === 0)
|
|
338
|
+
? remoteUsers
|
|
339
|
+
: localFilteredUsers;
|
|
340
|
+
return list.filter(u => !excludeSet.has(u.id));
|
|
341
|
+
}, [search, localFilteredUsers, remoteUsers, excludeSet]);
|
|
342
|
+
|
|
265
343
|
const isListLoading = loading || isSearching || isPendingFilter;
|
|
266
344
|
|
|
267
345
|
/* ---------- 6. Selection handlers ---------- */
|