@ermis-network/ermis-chat-react 1.0.8 → 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 +15295 -4209
- 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 +15246 -4186
- 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 +137 -16
- 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
|
@@ -0,0 +1,221 @@
|
|
|
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
|
+
systemMessageTranslations,
|
|
62
|
+
signalMessageTranslations,
|
|
63
|
+
}) => {
|
|
64
|
+
const { client, activeChannel, setActiveChannel } = useChatClient();
|
|
65
|
+
const currentUserId = client.userID;
|
|
66
|
+
const { topics } = useTopicGroupUpdates(channel, currentUserId);
|
|
67
|
+
|
|
68
|
+
// Ref for imperative scroll control on the virtualized list
|
|
69
|
+
const vlistRef = useRef<VListHandle>(null);
|
|
70
|
+
|
|
71
|
+
// Auto-scroll to top when the current user sends a message in any topic
|
|
72
|
+
useEffect(() => {
|
|
73
|
+
if (!scrollToTopOnOwnMessage || !currentUserId) return;
|
|
74
|
+
|
|
75
|
+
const subs: { unsubscribe: () => void }[] = [];
|
|
76
|
+
|
|
77
|
+
const handleNewMessage = (event: { user?: { id?: string } }) => {
|
|
78
|
+
if (event.user?.id === currentUserId) {
|
|
79
|
+
setTimeout(() => vlistRef.current?.scrollToIndex(0), 0);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
// Listen on parent channel
|
|
84
|
+
subs.push(channel.on('message.new', handleNewMessage));
|
|
85
|
+
|
|
86
|
+
// Listen on all sub-topics
|
|
87
|
+
const currentTopics = channel.state?.topics || [];
|
|
88
|
+
currentTopics.forEach((t: Channel) => {
|
|
89
|
+
subs.push(t.on('message.new', handleNewMessage));
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
return () => {
|
|
93
|
+
subs.forEach((s) => s.unsubscribe());
|
|
94
|
+
};
|
|
95
|
+
}, [channel, channel.state?.topics, currentUserId, scrollToTopOnOwnMessage]);
|
|
96
|
+
|
|
97
|
+
// Default edit topic handler: open built-in TopicModal when no custom handler is provided
|
|
98
|
+
const [editingTopic, setEditingTopic] = useState<Channel | null>(null);
|
|
99
|
+
|
|
100
|
+
const handleEditTopic = useCallback((topic: Channel) => {
|
|
101
|
+
if (onEditTopic) {
|
|
102
|
+
onEditTopic(topic);
|
|
103
|
+
} else {
|
|
104
|
+
setEditingTopic(topic);
|
|
105
|
+
}
|
|
106
|
+
}, [onEditTopic]);
|
|
107
|
+
|
|
108
|
+
// General channel proxy — display parent channel as the general topic
|
|
109
|
+
const generalProxy = useMemo(() => {
|
|
110
|
+
return new Proxy(channel, {
|
|
111
|
+
get(target, prop, receiver) {
|
|
112
|
+
if (prop === 'data') {
|
|
113
|
+
return { ...target.data, name: generalTopicLabel, is_pinned: false };
|
|
114
|
+
}
|
|
115
|
+
const value = Reflect.get(target, prop, receiver);
|
|
116
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
}, [channel, generalTopicLabel]);
|
|
120
|
+
|
|
121
|
+
const markChannelRead = useCallback((ch: Channel) => {
|
|
122
|
+
const ms = ch.state?.membership as Record<string, unknown> | undefined;
|
|
123
|
+
const chState = ch.state as unknown as Record<string, unknown> | undefined;
|
|
124
|
+
const isBannedInChannel = Boolean(ms?.banned);
|
|
125
|
+
const isPending = isPendingMember(ms?.channel_role as string);
|
|
126
|
+
const isSkipped = isSkippedMember(ms?.channel_role as string);
|
|
127
|
+
|
|
128
|
+
if (!isBannedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
|
|
129
|
+
ch.markRead().catch(() => { });
|
|
130
|
+
if (chState) chState.unreadCount = 0;
|
|
131
|
+
}
|
|
132
|
+
}, []);
|
|
133
|
+
|
|
134
|
+
const handleSelectGeneral = useCallback(() => {
|
|
135
|
+
if (onSelectTopic) {
|
|
136
|
+
onSelectTopic(channel);
|
|
137
|
+
} else {
|
|
138
|
+
setActiveChannel(channel);
|
|
139
|
+
}
|
|
140
|
+
markChannelRead(channel);
|
|
141
|
+
}, [channel, onSelectTopic, setActiveChannel, markChannelRead]);
|
|
142
|
+
|
|
143
|
+
const handleSelectTopic = useCallback((topic: Channel) => {
|
|
144
|
+
if (onSelectTopic) {
|
|
145
|
+
onSelectTopic(topic);
|
|
146
|
+
} else {
|
|
147
|
+
setActiveChannel(topic);
|
|
148
|
+
}
|
|
149
|
+
markChannelRead(topic);
|
|
150
|
+
}, [onSelectTopic, setActiveChannel, markChannelRead]);
|
|
151
|
+
|
|
152
|
+
/** Null actions component for the general item */
|
|
153
|
+
const NoActions = useCallback(() => null, []);
|
|
154
|
+
|
|
155
|
+
return (
|
|
156
|
+
<>
|
|
157
|
+
<VList ref={vlistRef} style={{ height: '100%' }}>
|
|
158
|
+
{/* General (parent channel) — no actions menu */}
|
|
159
|
+
<ChannelRow
|
|
160
|
+
channel={generalProxy as Channel}
|
|
161
|
+
isActive={activeChannel?.cid === channel.cid}
|
|
162
|
+
handleSelect={handleSelectGeneral}
|
|
163
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
164
|
+
AvatarComponent={GeneralAvatarComponent || DefaultGeneralAvatar as any}
|
|
165
|
+
currentUserId={currentUserId}
|
|
166
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
167
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
168
|
+
ChannelActionsComponent={NoActions}
|
|
169
|
+
hiddenActions={hiddenActions}
|
|
170
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
171
|
+
stickerMessageLabel={stickerMessageLabel}
|
|
172
|
+
photoMessageLabel={photoMessageLabel}
|
|
173
|
+
videoMessageLabel={videoMessageLabel}
|
|
174
|
+
voiceRecordingMessageLabel={voiceRecordingMessageLabel}
|
|
175
|
+
fileMessageLabel={fileMessageLabel}
|
|
176
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
177
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
178
|
+
/>
|
|
179
|
+
{/* Sub-topics — with full data (last msg, unread, timestamp, pin icon) */}
|
|
180
|
+
{topics.map((topic: Channel) => (
|
|
181
|
+
<ChannelRow
|
|
182
|
+
key={topic.cid}
|
|
183
|
+
channel={topic}
|
|
184
|
+
isActive={activeChannel?.cid === topic.cid}
|
|
185
|
+
handleSelect={handleSelectTopic}
|
|
186
|
+
ChannelItemComponent={ChannelItemComponent}
|
|
187
|
+
AvatarComponent={TopicAvatarComponent || DefaultTopicEmojiAvatar as any}
|
|
188
|
+
currentUserId={currentUserId}
|
|
189
|
+
pendingBadgeLabel={pendingBadgeLabel}
|
|
190
|
+
blockedBadgeLabel={blockedBadgeLabel}
|
|
191
|
+
closedTopicIcon={closedTopicIcon}
|
|
192
|
+
PinnedIconComponent={PinnedIconComponent}
|
|
193
|
+
ChannelActionsComponent={ChannelActionsComponent}
|
|
194
|
+
onEditTopic={handleEditTopic}
|
|
195
|
+
onToggleCloseTopic={onToggleCloseTopic}
|
|
196
|
+
onDeleteTopic={onDeleteTopic}
|
|
197
|
+
hiddenActions={hiddenActions}
|
|
198
|
+
actionLabels={actionLabels}
|
|
199
|
+
actionIcons={actionIcons}
|
|
200
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
201
|
+
stickerMessageLabel={stickerMessageLabel}
|
|
202
|
+
photoMessageLabel={photoMessageLabel}
|
|
203
|
+
videoMessageLabel={videoMessageLabel}
|
|
204
|
+
voiceRecordingMessageLabel={voiceRecordingMessageLabel}
|
|
205
|
+
fileMessageLabel={fileMessageLabel}
|
|
206
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
207
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
208
|
+
/>
|
|
209
|
+
))}
|
|
210
|
+
</VList>
|
|
211
|
+
{editingTopic && (
|
|
212
|
+
<TopicModal
|
|
213
|
+
isOpen={true}
|
|
214
|
+
onClose={() => setEditingTopic(null)}
|
|
215
|
+
topic={editingTopic}
|
|
216
|
+
/>
|
|
217
|
+
)}
|
|
218
|
+
</>
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
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,14 +12,21 @@ 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
32
|
<div className={`ermis-typing-indicator${isActive ? ' ermis-typing-indicator--active' : ''}`}>
|
|
@@ -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,6 +183,55 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
176
183
|
let active = true;
|
|
177
184
|
const fetchUsers = async () => {
|
|
178
185
|
if (!client) return;
|
|
186
|
+
|
|
187
|
+
const cacheKey = friendsOnly
|
|
188
|
+
? `${client.userID || 'anon'}-friends`
|
|
189
|
+
: `${client.userID || 'anon'}-${pageSize}`;
|
|
190
|
+
|
|
191
|
+
if (globalUsersCache[cacheKey] && globalUsersCache[cacheKey].users.length > 0) {
|
|
192
|
+
const cached = globalUsersCache[cacheKey];
|
|
193
|
+
setAllUsers(cached.users);
|
|
194
|
+
setHasMore(cached.hasMore);
|
|
195
|
+
setPage(cached.page);
|
|
196
|
+
setLoading(false);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
if (friendsOnly) {
|
|
201
|
+
const friends: UserPickerUser[] = [];
|
|
202
|
+
const seenIds = new Set<string>();
|
|
203
|
+
|
|
204
|
+
for (const channel of Object.values(client.activeChannels)) {
|
|
205
|
+
const members = channel.state?.members;
|
|
206
|
+
if (!members) continue;
|
|
207
|
+
|
|
208
|
+
for (const [memberId, member] of Object.entries(members)) {
|
|
209
|
+
if (memberId === client.userID) continue;
|
|
210
|
+
|
|
211
|
+
if (isFriendChannel(channel, memberId, client.userID as string) && !seenIds.has(memberId)) {
|
|
212
|
+
if (member.user) {
|
|
213
|
+
friends.push(member.user as UserPickerUser);
|
|
214
|
+
seenIds.add(memberId);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (active) {
|
|
221
|
+
setAllUsers(friends);
|
|
222
|
+
setHasMore(false);
|
|
223
|
+
setPage(1);
|
|
224
|
+
setLoading(false);
|
|
225
|
+
|
|
226
|
+
globalUsersCache[cacheKey] = {
|
|
227
|
+
users: friends,
|
|
228
|
+
page: 1,
|
|
229
|
+
hasMore: false,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
179
235
|
try {
|
|
180
236
|
setLoading(true);
|
|
181
237
|
const response = await client.queryUsers(String(pageSize), 1);
|
|
@@ -183,6 +239,12 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
183
239
|
setAllUsers(response.data);
|
|
184
240
|
setHasMore(response.data.length >= pageSize);
|
|
185
241
|
setPage(1);
|
|
242
|
+
|
|
243
|
+
globalUsersCache[cacheKey] = {
|
|
244
|
+
users: response.data,
|
|
245
|
+
page: 1,
|
|
246
|
+
hasMore: response.data.length >= pageSize
|
|
247
|
+
};
|
|
186
248
|
}
|
|
187
249
|
} catch (err) {
|
|
188
250
|
console.error('[UserPicker] Error fetching users:', err);
|
|
@@ -205,7 +267,18 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
205
267
|
setAllUsers(prev => {
|
|
206
268
|
const existingIds = new Set(prev.map(u => u.id));
|
|
207
269
|
const newUsers = response.data.filter((u: UserPickerUser) => !existingIds.has(u.id));
|
|
208
|
-
|
|
270
|
+
const combined = [...prev, ...newUsers];
|
|
271
|
+
|
|
272
|
+
if (client) {
|
|
273
|
+
const cacheKey = `${client.userID || 'anon'}-${pageSize}`;
|
|
274
|
+
globalUsersCache[cacheKey] = {
|
|
275
|
+
users: combined,
|
|
276
|
+
page: nextPage,
|
|
277
|
+
hasMore: response.data.length >= pageSize
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return combined;
|
|
209
282
|
});
|
|
210
283
|
setHasMore(response.data.length >= pageSize);
|
|
211
284
|
setPage(nextPage);
|
|
@@ -219,19 +292,19 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
219
292
|
|
|
220
293
|
/* ---------- 3. Local filter ---------- */
|
|
221
294
|
const localFilteredUsers = useMemo(() => {
|
|
222
|
-
const term = search.toLowerCase().trim();
|
|
295
|
+
const term = removeAccents(search.toLowerCase().trim());
|
|
223
296
|
if (!term) return allUsers;
|
|
224
297
|
return allUsers.filter(u => {
|
|
225
|
-
const name = (u.name || '').toLowerCase();
|
|
226
|
-
const email = (u.email || '').toLowerCase();
|
|
227
|
-
const phone = (u.phone || '').toLowerCase();
|
|
298
|
+
const name = removeAccents((u.name || '').toLowerCase());
|
|
299
|
+
const email = removeAccents((u.email || '').toLowerCase());
|
|
300
|
+
const phone = removeAccents((u.phone || '').toLowerCase());
|
|
228
301
|
return name.includes(term) || email.includes(term) || phone.includes(term);
|
|
229
302
|
});
|
|
230
303
|
}, [search, allUsers]);
|
|
231
304
|
|
|
232
305
|
/* ---------- 4. Remote search fallback ---------- */
|
|
233
306
|
useEffect(() => {
|
|
234
|
-
if (!search.trim() || localFilteredUsers.length > 0) {
|
|
307
|
+
if (!search.trim() || localFilteredUsers.length > 0 || friendsOnly) {
|
|
235
308
|
setRemoteUsers([]);
|
|
236
309
|
setIsSearching(false);
|
|
237
310
|
return;
|
|
@@ -259,9 +332,13 @@ export const UserPicker: React.FC<UserPickerProps> = ({
|
|
|
259
332
|
}, [search, localFilteredUsers.length, client]);
|
|
260
333
|
|
|
261
334
|
/* ---------- 5. Derived display list ---------- */
|
|
262
|
-
const usersToDisplay = (
|
|
263
|
-
|
|
264
|
-
|
|
335
|
+
const usersToDisplay = useMemo(() => {
|
|
336
|
+
const list = (search.trim() && localFilteredUsers.length === 0)
|
|
337
|
+
? remoteUsers
|
|
338
|
+
: localFilteredUsers;
|
|
339
|
+
return list.filter(u => !excludeSet.has(u.id));
|
|
340
|
+
}, [search, localFilteredUsers, remoteUsers, excludeSet]);
|
|
341
|
+
|
|
265
342
|
const isListLoading = loading || isSearching || isPendingFilter;
|
|
266
343
|
|
|
267
344
|
/* ---------- 6. Selection handlers ---------- */
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|
2
|
-
import { VList, type VListHandle } from 'virtua';
|
|
2
|
+
import { VList as _VList, type VListHandle } from 'virtua';
|
|
3
|
+
|
|
4
|
+
// Workaround for React 19 JSX element type mismatch with virtua's VList
|
|
5
|
+
const VList = _VList as any;
|
|
3
6
|
import type { MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
7
|
import { useChatClient } from '../hooks/useChatClient';
|
|
5
8
|
import { useBannedState } from '../hooks/useBannedState';
|
|
@@ -110,6 +113,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
110
113
|
messageRenderers: customRenderers,
|
|
111
114
|
loadMoreLimit = 25,
|
|
112
115
|
DateSeparatorComponent = DefaultDateSeparator,
|
|
116
|
+
dateLocale,
|
|
113
117
|
MessageItemComponent = MessageItem,
|
|
114
118
|
SystemMessageItemComponent = SystemMessageItem,
|
|
115
119
|
JumpToLatestButton = DefaultJumpToLatest,
|
|
@@ -144,15 +148,28 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
144
148
|
closedTopicReopenLabel = 'Reopen Topic',
|
|
145
149
|
PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
|
|
146
150
|
pendingInviteeLabel,
|
|
151
|
+
pinnedMessagesLabel,
|
|
152
|
+
seeAllLabel,
|
|
153
|
+
collapseLabel,
|
|
154
|
+
unpinLabel,
|
|
155
|
+
stickerLabel,
|
|
156
|
+
typingIndicatorLabel,
|
|
157
|
+
deletedMessageLabel = 'This message was deleted',
|
|
158
|
+
systemMessageTranslations,
|
|
159
|
+
signalMessageTranslations,
|
|
160
|
+
includeHiddenMessages = true,
|
|
161
|
+
onMentionClick,
|
|
162
|
+
onUserNameClick,
|
|
163
|
+
onAddReactionClick,
|
|
147
164
|
}) => {
|
|
148
165
|
const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
149
166
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
150
167
|
const { isBlocked } = useBlockedState(activeChannel, client.userID);
|
|
151
|
-
const { isPending } = usePendingState(activeChannel, client.userID);
|
|
152
|
-
|
|
153
|
-
const isSkipped = client.userID
|
|
154
|
-
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
155
|
-
|
|
168
|
+
const { isPending, inviteUpdateCount } = usePendingState(activeChannel, client.userID);
|
|
169
|
+
|
|
170
|
+
const isSkipped = client.userID
|
|
171
|
+
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
172
|
+
isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
|
|
156
173
|
: false;
|
|
157
174
|
|
|
158
175
|
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
@@ -179,7 +196,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
179
196
|
}
|
|
180
197
|
}
|
|
181
198
|
return null;
|
|
182
|
-
}, [activeChannel, currentUserId, isPending]);
|
|
199
|
+
}, [activeChannel, currentUserId, isPending, inviteUpdateCount]);
|
|
183
200
|
|
|
184
201
|
// Ref to scope DOM queries (safe for multiple instances)
|
|
185
202
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -190,13 +207,51 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
190
207
|
const handleAcceptInvite = useCallback(async () => {
|
|
191
208
|
if (!activeChannel) return;
|
|
192
209
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
210
|
+
let action: 'join' | 'accept' = 'accept';
|
|
211
|
+
if (isPublicGroupChannel(activeChannel)) {
|
|
212
|
+
const isMember = !!(currentUserId && activeChannel.state?.members?.[currentUserId]);
|
|
213
|
+
action = isMember ? 'accept' : 'join';
|
|
214
|
+
}
|
|
195
215
|
await activeChannel.acceptInvite(action);
|
|
216
|
+
|
|
217
|
+
// Optimistically update local membership so React picks up the change immediately.
|
|
218
|
+
// The async _handleChannelEvent in the SDK races with client listeners,
|
|
219
|
+
// so the WS event alone is not reliable for updating React state in time.
|
|
220
|
+
if (activeChannel.state && currentUserId) {
|
|
221
|
+
const updatedMembership = {
|
|
222
|
+
...activeChannel.state.membership,
|
|
223
|
+
channel_role: 'member',
|
|
224
|
+
user_id: currentUserId,
|
|
225
|
+
} as Record<string, unknown>;
|
|
226
|
+
activeChannel.state.membership = updatedMembership;
|
|
227
|
+
|
|
228
|
+
if (activeChannel.state.members?.[currentUserId]) {
|
|
229
|
+
activeChannel.state.members[currentUserId] = {
|
|
230
|
+
...activeChannel.state.members[currentUserId],
|
|
231
|
+
channel_role: 'member',
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Dispatch synthetic event so all React listeners update
|
|
236
|
+
const clientObj = activeChannel.getClient();
|
|
237
|
+
const eventType = action === 'join' ? 'member.joined' : 'notification.invite_accepted';
|
|
238
|
+
clientObj.dispatchEvent({
|
|
239
|
+
type: eventType,
|
|
240
|
+
cid: activeChannel.cid,
|
|
241
|
+
channel_type: activeChannel.type,
|
|
242
|
+
channel_id: activeChannel.id,
|
|
243
|
+
channel: activeChannel.data,
|
|
244
|
+
member: updatedMembership,
|
|
245
|
+
user: clientObj.user,
|
|
246
|
+
} as any);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// Re-watch to get full fresh state from server
|
|
250
|
+
activeChannel.watch().catch(() => {});
|
|
196
251
|
} catch (e: any) {
|
|
197
252
|
console.error('Error accepting invite', e);
|
|
198
253
|
}
|
|
199
|
-
}, [activeChannel]);
|
|
254
|
+
}, [activeChannel, currentUserId]);
|
|
200
255
|
|
|
201
256
|
const handleRejectInvite = useCallback(async () => {
|
|
202
257
|
if (!activeChannel) return;
|
|
@@ -281,6 +336,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
281
336
|
loadingMoreRef.current = false;
|
|
282
337
|
loadingNewerRef.current = false;
|
|
283
338
|
}, [setHasMore, setHasNewer]),
|
|
339
|
+
includeHiddenMessages,
|
|
340
|
+
containerRef,
|
|
284
341
|
});
|
|
285
342
|
|
|
286
343
|
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
|
|
@@ -336,7 +393,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
336
393
|
const showDateSeparator =
|
|
337
394
|
!prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
|
|
338
395
|
const dateSeparator = showDateSeparator ? (
|
|
339
|
-
<DateSeparatorComponent label={formatDateLabel(message.created_at)} />
|
|
396
|
+
<DateSeparatorComponent label={formatDateLabel(message.created_at, dateLocale)} />
|
|
340
397
|
) : null;
|
|
341
398
|
|
|
342
399
|
if (renderMessage) {
|
|
@@ -356,6 +413,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
356
413
|
message={message}
|
|
357
414
|
isOwnMessage={isOwnMessage}
|
|
358
415
|
SystemRenderer={renderers.system}
|
|
416
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
359
417
|
/>
|
|
360
418
|
</div>
|
|
361
419
|
);
|
|
@@ -363,12 +421,16 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
363
421
|
|
|
364
422
|
// Message grouping
|
|
365
423
|
const prevType = (prevMsg?.type || 'regular') as MessageLabel;
|
|
424
|
+
const prevValidReaders = prevMsg?.id && readByMap[prevMsg.id] ? readByMap[prevMsg.id].filter(r => r.id !== getMessageUserId(prevMsg)) : [];
|
|
425
|
+
const prevHasReaders = showReadReceipts && prevValidReaders.length > 0;
|
|
426
|
+
|
|
366
427
|
const isFirstInGroup =
|
|
367
428
|
showDateSeparator ||
|
|
368
429
|
!prevMsg ||
|
|
369
430
|
prevType === 'system' ||
|
|
370
431
|
prevType === 'signal' ||
|
|
371
|
-
getMessageUserId(prevMsg) !== getMessageUserId(message)
|
|
432
|
+
getMessageUserId(prevMsg) !== getMessageUserId(message) ||
|
|
433
|
+
prevHasReaders;
|
|
372
434
|
|
|
373
435
|
const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
|
|
374
436
|
const nextType = (nextMsg?.type || 'regular') as MessageLabel;
|
|
@@ -376,12 +438,16 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
376
438
|
? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
|
|
377
439
|
: false;
|
|
378
440
|
|
|
441
|
+
const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
|
|
442
|
+
const hasReaders = showReadReceipts && validReaders.length > 0;
|
|
443
|
+
|
|
379
444
|
const isLastInGroup =
|
|
380
445
|
!nextMsg ||
|
|
381
446
|
nextShowDateSeparator ||
|
|
382
447
|
nextType === 'system' ||
|
|
383
448
|
nextType === 'signal' ||
|
|
384
|
-
getMessageUserId(nextMsg) !== getMessageUserId(message)
|
|
449
|
+
getMessageUserId(nextMsg) !== getMessageUserId(message) ||
|
|
450
|
+
hasReaders;
|
|
385
451
|
|
|
386
452
|
const MessageRenderer = renderers[messageType] || renderers.regular;
|
|
387
453
|
|
|
@@ -401,11 +467,17 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
401
467
|
QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
|
|
402
468
|
MessageActionsBoxComponent={MessageActionsBoxComponent}
|
|
403
469
|
MessageReactionsComponent={MessageReactionsComponent}
|
|
470
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
471
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
472
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
473
|
+
onMentionClick={onMentionClick}
|
|
474
|
+
onUserNameClick={onUserNameClick}
|
|
475
|
+
onAddReactionClick={onAddReactionClick}
|
|
404
476
|
/>
|
|
405
477
|
{/* Read receipts — full width, right-aligned */}
|
|
406
|
-
{showReadReceipts && (
|
|
478
|
+
{showReadReceipts && validReaders.length > 0 && (
|
|
407
479
|
<ReadReceiptsComponent
|
|
408
|
-
readers={
|
|
480
|
+
readers={validReaders}
|
|
409
481
|
maxAvatars={readReceiptsMaxAvatars}
|
|
410
482
|
AvatarComponent={AvatarComponent}
|
|
411
483
|
TooltipComponent={ReadReceiptsTooltipComponent}
|
|
@@ -437,6 +509,10 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
437
509
|
ReadReceiptsComponent,
|
|
438
510
|
ReadReceiptsTooltipComponent,
|
|
439
511
|
readReceiptsMaxAvatars,
|
|
512
|
+
dateLocale,
|
|
513
|
+
onMentionClick,
|
|
514
|
+
onUserNameClick,
|
|
515
|
+
onAddReactionClick,
|
|
440
516
|
]);
|
|
441
517
|
|
|
442
518
|
if (isBanned || isBlocked) {
|
|
@@ -499,7 +575,17 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
499
575
|
|
|
500
576
|
return (
|
|
501
577
|
<div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
|
|
502
|
-
{showPinnedMessages &&
|
|
578
|
+
{showPinnedMessages && (
|
|
579
|
+
<PinnedMessagesComponent
|
|
580
|
+
onClickMessage={scrollToMessage}
|
|
581
|
+
AvatarComponent={AvatarComponent}
|
|
582
|
+
pinnedMessagesLabel={pinnedMessagesLabel}
|
|
583
|
+
seeAllLabel={seeAllLabel}
|
|
584
|
+
collapseLabel={collapseLabel}
|
|
585
|
+
unpinLabel={unpinLabel}
|
|
586
|
+
stickerLabel={stickerLabel}
|
|
587
|
+
/>
|
|
588
|
+
)}
|
|
503
589
|
|
|
504
590
|
{messages.length === 0 && (
|
|
505
591
|
EmptyStateIndicator === DefaultEmpty
|
|
@@ -508,9 +594,9 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
508
594
|
)}
|
|
509
595
|
|
|
510
596
|
{pendingInviteeName && (
|
|
511
|
-
<PendingInviteeNotificationComponent
|
|
512
|
-
inviteeName={pendingInviteeName}
|
|
513
|
-
label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
|
|
597
|
+
<PendingInviteeNotificationComponent
|
|
598
|
+
inviteeName={pendingInviteeName}
|
|
599
|
+
label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
|
|
514
600
|
/>
|
|
515
601
|
)}
|
|
516
602
|
|
|
@@ -525,7 +611,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
525
611
|
</VList>
|
|
526
612
|
|
|
527
613
|
{/* Typing indicator */}
|
|
528
|
-
{showTypingIndicator && <TypingIndicatorComponent />}
|
|
614
|
+
{showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
|
|
529
615
|
|
|
530
616
|
{/* Jump to latest button */}
|
|
531
617
|
{hasNewer && (
|