@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
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|
2
|
-
import { VList, type VListHandle } from 'virtua';
|
|
1
|
+
import React, { useState, useRef, useCallback, useMemo, useEffect, useLayoutEffect } from 'react';
|
|
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';
|
|
@@ -18,6 +21,7 @@ import {
|
|
|
18
21
|
defaultMessageRenderers,
|
|
19
22
|
type MessageBubbleProps,
|
|
20
23
|
} from './MessageRenderers';
|
|
24
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
21
25
|
import { getDateKey, formatDateLabel, getMessageUserId, formatReadTimestamp } from '../utils';
|
|
22
26
|
import { QuotedMessagePreview } from './QuotedMessagePreview';
|
|
23
27
|
import { PinnedMessages } from './PinnedMessages';
|
|
@@ -41,6 +45,20 @@ const DefaultDateSeparator: React.FC<{ label: string }> = React.memo(({ label })
|
|
|
41
45
|
));
|
|
42
46
|
(DefaultDateSeparator as any).displayName = 'DefaultDateSeparator';
|
|
43
47
|
|
|
48
|
+
/** Time gap threshold in ms: messages more than 5 minutes apart get a time separator */
|
|
49
|
+
const TIME_GAP_THRESHOLD_MS = 5 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
function getTimestamp(date: Date | string | undefined): number {
|
|
52
|
+
if (!date) return 0;
|
|
53
|
+
return date instanceof Date ? date.getTime() : new Date(date).getTime();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function formatTimeSeparator(date: Date | string | undefined): string {
|
|
57
|
+
if (!date) return '';
|
|
58
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
59
|
+
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
60
|
+
}
|
|
61
|
+
|
|
44
62
|
const DefaultJumpToLatest = React.memo(({ onClick, label = '↓ Jump to latest' }: any) => (
|
|
45
63
|
<button className="ermis-message-list__jump-latest" onClick={onClick}>
|
|
46
64
|
{label}
|
|
@@ -110,6 +128,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
110
128
|
messageRenderers: customRenderers,
|
|
111
129
|
loadMoreLimit = 25,
|
|
112
130
|
DateSeparatorComponent = DefaultDateSeparator,
|
|
131
|
+
dateLocale,
|
|
113
132
|
MessageItemComponent = MessageItem,
|
|
114
133
|
SystemMessageItemComponent = SystemMessageItem,
|
|
115
134
|
JumpToLatestButton = DefaultJumpToLatest,
|
|
@@ -144,15 +163,34 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
144
163
|
closedTopicReopenLabel = 'Reopen Topic',
|
|
145
164
|
PendingInviteeNotificationComponent = DefaultPendingInviteeNotification,
|
|
146
165
|
pendingInviteeLabel,
|
|
166
|
+
pinnedMessagesLabel,
|
|
167
|
+
seeAllLabel,
|
|
168
|
+
collapseLabel,
|
|
169
|
+
unpinLabel,
|
|
170
|
+
stickerLabel,
|
|
171
|
+
attachmentLabel = 'Attachment',
|
|
172
|
+
unavailableMessageLabel = 'Message unavailable',
|
|
173
|
+
encryptedMessageLabel,
|
|
174
|
+
encryptedMessageFailedLabel,
|
|
175
|
+
encryptedMessageDecryptingLabel,
|
|
176
|
+
encryptedMessageUnavailableLabel,
|
|
177
|
+
typingIndicatorLabel,
|
|
178
|
+
deletedMessageLabel = 'This message was deleted',
|
|
179
|
+
systemMessageTranslations,
|
|
180
|
+
signalMessageTranslations,
|
|
181
|
+
includeHiddenMessages = true,
|
|
182
|
+
onMentionClick,
|
|
183
|
+
onUserNameClick,
|
|
184
|
+
onAddReactionClick,
|
|
147
185
|
}) => {
|
|
148
186
|
const { client, messages, readState, activeChannel, setActiveChannel, jumpToMessageId, setJumpToMessageId } = useChatClient();
|
|
149
187
|
const { isBanned } = useBannedState(activeChannel, client.userID);
|
|
150
188
|
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
|
-
|
|
189
|
+
const { isPending, inviteUpdateCount } = usePendingState(activeChannel, client.userID);
|
|
190
|
+
|
|
191
|
+
const isSkipped = client.userID
|
|
192
|
+
? isSkippedMember(activeChannel?.state?.members?.[client.userID]?.channel_role as string) ||
|
|
193
|
+
isSkippedMember(activeChannel?.state?.membership?.channel_role as string)
|
|
156
194
|
: false;
|
|
157
195
|
|
|
158
196
|
const isClosedTopic = activeChannel?.data?.is_closed_topic === true;
|
|
@@ -179,7 +217,7 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
179
217
|
}
|
|
180
218
|
}
|
|
181
219
|
return null;
|
|
182
|
-
}, [activeChannel, currentUserId, isPending]);
|
|
220
|
+
}, [activeChannel, currentUserId, isPending, inviteUpdateCount]);
|
|
183
221
|
|
|
184
222
|
// Ref to scope DOM queries (safe for multiple instances)
|
|
185
223
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
@@ -190,13 +228,49 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
190
228
|
const handleAcceptInvite = useCallback(async () => {
|
|
191
229
|
if (!activeChannel) return;
|
|
192
230
|
try {
|
|
193
|
-
|
|
194
|
-
|
|
231
|
+
let action: 'join' | 'accept' = 'accept';
|
|
232
|
+
if (isPublicGroupChannel(activeChannel)) {
|
|
233
|
+
const isMember = !!(currentUserId && activeChannel.state?.members?.[currentUserId]);
|
|
234
|
+
action = isMember ? 'accept' : 'join';
|
|
235
|
+
}
|
|
195
236
|
await activeChannel.acceptInvite(action);
|
|
237
|
+
|
|
238
|
+
// Optimistically update local membership so React picks up the change immediately.
|
|
239
|
+
// The async _handleChannelEvent in the SDK races with client listeners,
|
|
240
|
+
// so the WS event alone is not reliable for updating React state in time.
|
|
241
|
+
if (activeChannel.state && currentUserId) {
|
|
242
|
+
const updatedMembership = {
|
|
243
|
+
...activeChannel.state.membership,
|
|
244
|
+
channel_role: 'member',
|
|
245
|
+
user_id: currentUserId,
|
|
246
|
+
} as Record<string, unknown>;
|
|
247
|
+
activeChannel.state.membership = updatedMembership;
|
|
248
|
+
|
|
249
|
+
if (activeChannel.state.members?.[currentUserId]) {
|
|
250
|
+
activeChannel.state.members[currentUserId] = {
|
|
251
|
+
...activeChannel.state.members[currentUserId],
|
|
252
|
+
channel_role: 'member',
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// Dispatch synthetic event so all React listeners update
|
|
257
|
+
const clientObj = activeChannel.getClient();
|
|
258
|
+
const eventType = action === 'join' ? 'member.joined' : 'notification.invite_accepted';
|
|
259
|
+
clientObj.dispatchEvent({
|
|
260
|
+
type: eventType,
|
|
261
|
+
cid: activeChannel.cid,
|
|
262
|
+
channel_type: activeChannel.type,
|
|
263
|
+
channel_id: activeChannel.id,
|
|
264
|
+
channel: activeChannel.data,
|
|
265
|
+
member: updatedMembership,
|
|
266
|
+
user: clientObj.user,
|
|
267
|
+
} as any);
|
|
268
|
+
}
|
|
269
|
+
|
|
196
270
|
} catch (e: any) {
|
|
197
271
|
console.error('Error accepting invite', e);
|
|
198
272
|
}
|
|
199
|
-
}, [activeChannel]);
|
|
273
|
+
}, [activeChannel, currentUserId]);
|
|
200
274
|
|
|
201
275
|
const handleRejectInvite = useCallback(async () => {
|
|
202
276
|
if (!activeChannel) return;
|
|
@@ -218,11 +292,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
218
292
|
}
|
|
219
293
|
}, [activeChannel, setActiveChannel]);
|
|
220
294
|
|
|
295
|
+
const elementsCountRef = useRef(0);
|
|
296
|
+
|
|
221
297
|
const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
|
|
222
298
|
const handle = vlistRef.current;
|
|
223
299
|
if (!handle) return;
|
|
224
300
|
|
|
225
|
-
const count =
|
|
301
|
+
const count = elementsCountRef.current;
|
|
226
302
|
if (count === 0) return;
|
|
227
303
|
|
|
228
304
|
// Ensure virtua has measured the viewport via ResizeObserver.
|
|
@@ -232,11 +308,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
232
308
|
return;
|
|
233
309
|
}
|
|
234
310
|
|
|
311
|
+
if (!smooth && handle.scrollSize > handle.viewportSize) {
|
|
312
|
+
handle.scrollTo(Math.max(0, handle.scrollSize - handle.viewportSize));
|
|
313
|
+
}
|
|
235
314
|
handle.scrollToIndex(count - 1, { align: 'end', smooth });
|
|
236
315
|
}, []);
|
|
237
316
|
|
|
238
317
|
// Shared guard: skip scroll-triggered loads during jump transitions
|
|
239
318
|
const jumpingRef = useRef(false);
|
|
319
|
+
const scrollLoadLockRef = useRef(false);
|
|
320
|
+
const scrollLoadLockTokenRef = useRef(0);
|
|
321
|
+
const holdScrollLoadLock = useCallback((duration = 750) => {
|
|
322
|
+
const token = scrollLoadLockTokenRef.current + 1;
|
|
323
|
+
scrollLoadLockTokenRef.current = token;
|
|
324
|
+
scrollLoadLockRef.current = true;
|
|
325
|
+
setTimeout(() => {
|
|
326
|
+
if (scrollLoadLockTokenRef.current === token) {
|
|
327
|
+
scrollLoadLockRef.current = false;
|
|
328
|
+
}
|
|
329
|
+
}, duration);
|
|
330
|
+
}, []);
|
|
240
331
|
|
|
241
332
|
/* ---------- Hooks ---------- */
|
|
242
333
|
const {
|
|
@@ -250,9 +341,23 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
250
341
|
vlistRef,
|
|
251
342
|
messagesRef,
|
|
252
343
|
jumpingRef,
|
|
344
|
+
scrollLoadLockRef,
|
|
253
345
|
loadMoreLimit,
|
|
254
346
|
});
|
|
255
347
|
|
|
348
|
+
const isNearBottom = useCallback(() => {
|
|
349
|
+
const handle = vlistRef.current;
|
|
350
|
+
if (!handle) return isAtBottomRef.current;
|
|
351
|
+
|
|
352
|
+
const { scrollOffset, scrollSize, viewportSize } = handle;
|
|
353
|
+
if (!Number.isFinite(scrollOffset) || !Number.isFinite(scrollSize) || !Number.isFinite(viewportSize)) {
|
|
354
|
+
return isAtBottomRef.current;
|
|
355
|
+
}
|
|
356
|
+
if (scrollSize <= viewportSize || viewportSize <= 0) return true;
|
|
357
|
+
|
|
358
|
+
return scrollSize - (scrollOffset + viewportSize) <= 160;
|
|
359
|
+
}, [isAtBottomRef]);
|
|
360
|
+
|
|
256
361
|
const { highlightedId, scrollToMessage, jumpToLatest } = useScrollToMessage({
|
|
257
362
|
vlistRef,
|
|
258
363
|
messagesRef,
|
|
@@ -273,6 +378,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
273
378
|
|
|
274
379
|
useChannelMessages({
|
|
275
380
|
scrollToBottom,
|
|
381
|
+
isNearBottom,
|
|
382
|
+
holdScrollLoadLock,
|
|
276
383
|
jumpingRef,
|
|
277
384
|
isAtBottomRef,
|
|
278
385
|
onChannelSwitch: useCallback(() => {
|
|
@@ -281,8 +388,38 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
281
388
|
loadingMoreRef.current = false;
|
|
282
389
|
loadingNewerRef.current = false;
|
|
283
390
|
}, [setHasMore, setHasNewer]),
|
|
391
|
+
includeHiddenMessages,
|
|
392
|
+
containerRef,
|
|
284
393
|
});
|
|
285
394
|
|
|
395
|
+
const lastAutoScrollKeyRef = useRef<string | null>(null);
|
|
396
|
+
|
|
397
|
+
useLayoutEffect(() => {
|
|
398
|
+
const lastMessage = messages[messages.length - 1];
|
|
399
|
+
if (!lastMessage?.id || !currentUserId) return;
|
|
400
|
+
|
|
401
|
+
const key = `${activeChannel?.cid || ''}:${lastMessage.id}`;
|
|
402
|
+
if (lastAutoScrollKeyRef.current === key) return;
|
|
403
|
+
|
|
404
|
+
const isOwnLastMessage =
|
|
405
|
+
lastMessage.user_id === currentUserId || lastMessage.user?.id === currentUserId;
|
|
406
|
+
if (!isOwnLastMessage && !isAtBottomRef.current && !isNearBottom()) return;
|
|
407
|
+
if (!isOwnLastMessage && jumpingRef.current) return;
|
|
408
|
+
if (loadingMoreRef.current || loadingNewerRef.current) return;
|
|
409
|
+
|
|
410
|
+
lastAutoScrollKeyRef.current = key;
|
|
411
|
+
isAtBottomRef.current = true;
|
|
412
|
+
holdScrollLoadLock(750);
|
|
413
|
+
|
|
414
|
+
scrollToBottom(false);
|
|
415
|
+
requestAnimationFrame(() => {
|
|
416
|
+
requestAnimationFrame(() => scrollToBottom(false));
|
|
417
|
+
});
|
|
418
|
+
setTimeout(() => scrollToBottom(false), 80);
|
|
419
|
+
setTimeout(() => scrollToBottom(false), 180);
|
|
420
|
+
setTimeout(() => scrollToBottom(false), 360);
|
|
421
|
+
}, [activeChannel?.cid, currentUserId, messages, scrollToBottom, isNearBottom, holdScrollLoadLock]);
|
|
422
|
+
|
|
286
423
|
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
|
|
287
424
|
const prevOverlayRef = useRef(hasOverlay);
|
|
288
425
|
|
|
@@ -326,97 +463,245 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
326
463
|
|
|
327
464
|
/* ---------- Memoized message elements ---------- */
|
|
328
465
|
const messageElements = useMemo(() => {
|
|
329
|
-
|
|
466
|
+
const elements: React.ReactNode[] = [];
|
|
467
|
+
|
|
468
|
+
// Pre-compute per-message data
|
|
469
|
+
type MsgEntry = {
|
|
470
|
+
message: typeof messages[0];
|
|
471
|
+
index: number;
|
|
472
|
+
isOwnMessage: boolean;
|
|
473
|
+
messageType: MessageLabel;
|
|
474
|
+
showDateSeparator: boolean;
|
|
475
|
+
isFirstInGroup: boolean;
|
|
476
|
+
isLastInGroup: boolean;
|
|
477
|
+
validReaders: Array<{ id: string; name?: string; avatar?: string; last_read?: Date | string }>;
|
|
478
|
+
hasReaders: boolean;
|
|
479
|
+
};
|
|
480
|
+
const entries: MsgEntry[] = messages.map((message, index) => {
|
|
330
481
|
const isOwnMessage =
|
|
331
482
|
message.user_id === currentUserId || message.user?.id === currentUserId;
|
|
332
|
-
const messageType = (
|
|
483
|
+
const messageType = (
|
|
484
|
+
isStickerMessage(message) ? 'sticker' : (message.type || 'regular')
|
|
485
|
+
) as MessageLabel;
|
|
333
486
|
|
|
334
487
|
// Date separator
|
|
335
488
|
const prevMsg = index > 0 ? messages[index - 1] : null;
|
|
336
489
|
const showDateSeparator =
|
|
337
490
|
!prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
|
|
338
|
-
const dateSeparator = showDateSeparator ? (
|
|
339
|
-
<DateSeparatorComponent label={formatDateLabel(message.created_at)} />
|
|
340
|
-
) : null;
|
|
341
|
-
|
|
342
|
-
if (renderMessage) {
|
|
343
|
-
return (
|
|
344
|
-
<div key={message.id || `msg-${index}`}>
|
|
345
|
-
{dateSeparator}
|
|
346
|
-
<div>{renderMessage(message, isOwnMessage)}</div>
|
|
347
|
-
</div>
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
if (messageType === 'system') {
|
|
352
|
-
return (
|
|
353
|
-
<div key={message.id || `msg-${index}`}>
|
|
354
|
-
{dateSeparator}
|
|
355
|
-
<SystemMessageItemComponent
|
|
356
|
-
message={message}
|
|
357
|
-
isOwnMessage={isOwnMessage}
|
|
358
|
-
SystemRenderer={renderers.system}
|
|
359
|
-
/>
|
|
360
|
-
</div>
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Message grouping
|
|
365
491
|
const prevType = (prevMsg?.type || 'regular') as MessageLabel;
|
|
492
|
+
const prevTimeGap = prevMsg
|
|
493
|
+
? Math.abs(getTimestamp(message.created_at) - getTimestamp(prevMsg.created_at)) > TIME_GAP_THRESHOLD_MS
|
|
494
|
+
: false;
|
|
366
495
|
const isFirstInGroup =
|
|
367
496
|
showDateSeparator ||
|
|
368
497
|
!prevMsg ||
|
|
369
498
|
prevType === 'system' ||
|
|
370
499
|
prevType === 'signal' ||
|
|
371
|
-
getMessageUserId(prevMsg) !== getMessageUserId(message)
|
|
372
|
-
|
|
500
|
+
getMessageUserId(prevMsg) !== getMessageUserId(message) ||
|
|
501
|
+
prevTimeGap;
|
|
373
502
|
const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
|
|
374
503
|
const nextType = (nextMsg?.type || 'regular') as MessageLabel;
|
|
375
504
|
const nextShowDateSeparator = nextMsg
|
|
376
505
|
? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
|
|
377
506
|
: false;
|
|
378
|
-
|
|
507
|
+
const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
|
|
508
|
+
const hasReaders = showReadReceipts && validReaders.length > 0;
|
|
509
|
+
const nextTimeGap = nextMsg
|
|
510
|
+
? Math.abs(getTimestamp(nextMsg.created_at) - getTimestamp(message.created_at)) > TIME_GAP_THRESHOLD_MS
|
|
511
|
+
: false;
|
|
379
512
|
const isLastInGroup =
|
|
380
513
|
!nextMsg ||
|
|
381
514
|
nextShowDateSeparator ||
|
|
382
515
|
nextType === 'system' ||
|
|
383
516
|
nextType === 'signal' ||
|
|
384
|
-
getMessageUserId(nextMsg) !== getMessageUserId(message)
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
{
|
|
407
|
-
<
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
517
|
+
getMessageUserId(nextMsg) !== getMessageUserId(message) ||
|
|
518
|
+
nextTimeGap;
|
|
519
|
+
return { message, index, isOwnMessage, messageType, showDateSeparator, isFirstInGroup, isLastInGroup, validReaders, hasReaders };
|
|
520
|
+
});
|
|
521
|
+
|
|
522
|
+
// Build groups: consecutive regular messages from same user
|
|
523
|
+
let i = 0;
|
|
524
|
+
while (i < entries.length) {
|
|
525
|
+
const entry = entries[i];
|
|
526
|
+
|
|
527
|
+
// Date separator before any message
|
|
528
|
+
if (entry.showDateSeparator) {
|
|
529
|
+
elements.push(
|
|
530
|
+
<div key={`date-${getDateKey(entry.message.created_at)}`}>
|
|
531
|
+
<DateSeparatorComponent label={formatDateLabel(entry.message.created_at, dateLocale)} />
|
|
532
|
+
</div>
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Custom renderMessage
|
|
537
|
+
if (renderMessage) {
|
|
538
|
+
elements.push(
|
|
539
|
+
<div key={entry.message.id || `msg-${entry.index}`}>
|
|
540
|
+
<div>{renderMessage(entry.message, entry.isOwnMessage)}</div>
|
|
541
|
+
</div>
|
|
542
|
+
);
|
|
543
|
+
i++;
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// System messages — standalone
|
|
548
|
+
if (entry.messageType === 'system') {
|
|
549
|
+
elements.push(
|
|
550
|
+
<div key={entry.message.id || `msg-${entry.index}`}>
|
|
551
|
+
<SystemMessageItemComponent
|
|
552
|
+
message={entry.message}
|
|
553
|
+
isOwnMessage={entry.isOwnMessage}
|
|
554
|
+
SystemRenderer={renderers.system}
|
|
555
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
415
556
|
/>
|
|
416
|
-
|
|
557
|
+
</div>
|
|
558
|
+
);
|
|
559
|
+
i++;
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Collect consecutive regular/signal messages from the same user into a group
|
|
564
|
+
// Break group on: different user, system message, date separator, or time gap > 5min
|
|
565
|
+
const groupEntries: MsgEntry[] = [entry];
|
|
566
|
+
let j = i + 1;
|
|
567
|
+
while (j < entries.length) {
|
|
568
|
+
const nextEntry = entries[j];
|
|
569
|
+
const prevEntry = entries[j - 1];
|
|
570
|
+
const timeGap = Math.abs(
|
|
571
|
+
getTimestamp(nextEntry.message.created_at) - getTimestamp(prevEntry.message.created_at)
|
|
572
|
+
);
|
|
573
|
+
// Break group if: different user, system message, date separator, or time gap
|
|
574
|
+
if (
|
|
575
|
+
nextEntry.showDateSeparator ||
|
|
576
|
+
nextEntry.messageType === 'system' ||
|
|
577
|
+
getMessageUserId(nextEntry.message) !== getMessageUserId(entry.message) ||
|
|
578
|
+
timeGap > TIME_GAP_THRESHOLD_MS
|
|
579
|
+
) {
|
|
580
|
+
break;
|
|
581
|
+
}
|
|
582
|
+
groupEntries.push(nextEntry);
|
|
583
|
+
j++;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const isOwn = entry.isOwnMessage;
|
|
587
|
+
const userName = entry.message.user?.name || entry.message.user_id;
|
|
588
|
+
const userAvatar = entry.message.user?.avatar;
|
|
589
|
+
const groupKey = `group-${entry.message.id || `g-${entry.index}`}`;
|
|
590
|
+
|
|
591
|
+
// Check if we need a time separator BEFORE this group
|
|
592
|
+
// (when previous group was from same user but time gap split them)
|
|
593
|
+
if (i > 0) {
|
|
594
|
+
const prevEntry = entries[i - 1];
|
|
595
|
+
const timeGap = Math.abs(
|
|
596
|
+
getTimestamp(entry.message.created_at) - getTimestamp(prevEntry.message.created_at)
|
|
597
|
+
);
|
|
598
|
+
if (
|
|
599
|
+
!entry.showDateSeparator &&
|
|
600
|
+
prevEntry.messageType !== 'system' &&
|
|
601
|
+
getMessageUserId(prevEntry.message) === getMessageUserId(entry.message) &&
|
|
602
|
+
timeGap > TIME_GAP_THRESHOLD_MS
|
|
603
|
+
) {
|
|
604
|
+
elements.push(
|
|
605
|
+
<div key={`timesep-${entry.message.id}`}>
|
|
606
|
+
<div className="ermis-message-list__time-separator">
|
|
607
|
+
<span className="ermis-message-list__time-separator-label">
|
|
608
|
+
{formatTimeSeparator(entry.message.created_at)}
|
|
609
|
+
</span>
|
|
610
|
+
</div>
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// Render group wrapper with sticky avatar
|
|
617
|
+
elements.push(
|
|
618
|
+
<div key={groupKey}>
|
|
619
|
+
<div className={`ermis-message-group ${isOwn ? 'ermis-message-group--own' : 'ermis-message-group--other'}`}>
|
|
620
|
+
{/* Avatar column — sticky for scroll tracking */}
|
|
621
|
+
{!isOwn && (
|
|
622
|
+
<div className="ermis-message-group__avatar-col">
|
|
623
|
+
<AvatarComponent image={userAvatar} name={userName} size={36} />
|
|
624
|
+
</div>
|
|
625
|
+
)}
|
|
626
|
+
{/* Messages column */}
|
|
627
|
+
<div className="ermis-message-group__messages-col">
|
|
628
|
+
{groupEntries.map((ge) => {
|
|
629
|
+
const MessageRenderer = renderers[ge.messageType] || renderers.regular;
|
|
630
|
+
return (
|
|
631
|
+
<React.Fragment key={ge.message.id || `msg-${ge.index}`}>
|
|
632
|
+
{/* Date separators within group (if needed for mid-group entries) */}
|
|
633
|
+
{ge !== entry && ge.showDateSeparator && (
|
|
634
|
+
<DateSeparatorComponent label={formatDateLabel(ge.message.created_at, dateLocale)} />
|
|
635
|
+
)}
|
|
636
|
+
<MessageItemComponent
|
|
637
|
+
message={ge.message}
|
|
638
|
+
isOwnMessage={ge.isOwnMessage}
|
|
639
|
+
isFirstInGroup={ge.isFirstInGroup}
|
|
640
|
+
isLastInGroup={ge.isLastInGroup}
|
|
641
|
+
isHighlighted={highlightedId === ge.message.id}
|
|
642
|
+
AvatarComponent={AvatarComponent}
|
|
643
|
+
MessageBubble={MessageBubble}
|
|
644
|
+
MessageRenderer={MessageRenderer}
|
|
645
|
+
onClickQuote={scrollToMessage}
|
|
646
|
+
QuotedMessagePreviewComponent={QuotedMessagePreviewComponent}
|
|
647
|
+
MessageActionsBoxComponent={MessageActionsBoxComponent}
|
|
648
|
+
MessageReactionsComponent={MessageReactionsComponent}
|
|
649
|
+
deletedMessageLabel={deletedMessageLabel}
|
|
650
|
+
attachmentLabel={attachmentLabel}
|
|
651
|
+
unavailableMessageLabel={unavailableMessageLabel}
|
|
652
|
+
stickerLabel={stickerLabel}
|
|
653
|
+
encryptedMessageLabel={encryptedMessageLabel}
|
|
654
|
+
encryptedMessageFailedLabel={encryptedMessageFailedLabel}
|
|
655
|
+
encryptedMessageDecryptingLabel={encryptedMessageDecryptingLabel}
|
|
656
|
+
systemMessageTranslations={systemMessageTranslations}
|
|
657
|
+
signalMessageTranslations={signalMessageTranslations}
|
|
658
|
+
onMentionClick={onMentionClick}
|
|
659
|
+
onUserNameClick={onUserNameClick}
|
|
660
|
+
onAddReactionClick={onAddReactionClick}
|
|
661
|
+
hideAvatar
|
|
662
|
+
/>
|
|
663
|
+
</React.Fragment>
|
|
664
|
+
);
|
|
665
|
+
})}
|
|
666
|
+
</div>
|
|
667
|
+
</div>
|
|
668
|
+
{/* Read receipts — consolidated: merge all readers in this group into one row */}
|
|
669
|
+
{(() => {
|
|
670
|
+
if (!showReadReceipts) return null;
|
|
671
|
+
const allReaders: Array<{ id: string; name?: string; avatar?: string; last_read?: Date | string }> = [];
|
|
672
|
+
const seen = new Set<string>();
|
|
673
|
+
for (const ge of groupEntries) {
|
|
674
|
+
for (const r of ge.validReaders) {
|
|
675
|
+
if (!seen.has(r.id)) {
|
|
676
|
+
seen.add(r.id);
|
|
677
|
+
allReaders.push(r);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
if (allReaders.length === 0) return null;
|
|
682
|
+
const lastEntry = groupEntries[groupEntries.length - 1];
|
|
683
|
+
return (
|
|
684
|
+
<ReadReceiptsComponent
|
|
685
|
+
key={`receipt-${lastEntry.message.id}`}
|
|
686
|
+
readers={allReaders}
|
|
687
|
+
maxAvatars={readReceiptsMaxAvatars}
|
|
688
|
+
AvatarComponent={AvatarComponent}
|
|
689
|
+
TooltipComponent={ReadReceiptsTooltipComponent}
|
|
690
|
+
isOwnMessage={lastEntry.isOwnMessage}
|
|
691
|
+
isLastInGroup={lastEntry.isLastInGroup}
|
|
692
|
+
status={lastEntry.message.status}
|
|
693
|
+
/>
|
|
694
|
+
);
|
|
695
|
+
})()}
|
|
417
696
|
</div>
|
|
418
697
|
);
|
|
419
|
-
|
|
698
|
+
|
|
699
|
+
i = j;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
elementsCountRef.current = elements.length;
|
|
704
|
+
return elements;
|
|
420
705
|
}, [
|
|
421
706
|
messages,
|
|
422
707
|
currentUserId,
|
|
@@ -437,6 +722,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
437
722
|
ReadReceiptsComponent,
|
|
438
723
|
ReadReceiptsTooltipComponent,
|
|
439
724
|
readReceiptsMaxAvatars,
|
|
725
|
+
dateLocale,
|
|
726
|
+
onMentionClick,
|
|
727
|
+
onUserNameClick,
|
|
728
|
+
onAddReactionClick,
|
|
729
|
+
encryptedMessageLabel,
|
|
730
|
+
encryptedMessageFailedLabel,
|
|
731
|
+
encryptedMessageDecryptingLabel,
|
|
440
732
|
]);
|
|
441
733
|
|
|
442
734
|
if (isBanned || isBlocked) {
|
|
@@ -498,42 +790,56 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
498
790
|
}
|
|
499
791
|
|
|
500
792
|
return (
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
793
|
+
<>
|
|
794
|
+
<div ref={containerRef} className={`ermis-message-list${className ? ` ${className}` : ''}`}>
|
|
795
|
+
{showPinnedMessages && (
|
|
796
|
+
<PinnedMessagesComponent
|
|
797
|
+
onClickMessage={scrollToMessage}
|
|
798
|
+
AvatarComponent={AvatarComponent}
|
|
799
|
+
pinnedMessagesLabel={pinnedMessagesLabel}
|
|
800
|
+
seeAllLabel={seeAllLabel}
|
|
801
|
+
collapseLabel={collapseLabel}
|
|
802
|
+
unpinLabel={unpinLabel}
|
|
803
|
+
stickerLabel={stickerLabel}
|
|
804
|
+
attachmentLabel={attachmentLabel}
|
|
805
|
+
unavailableMessageLabel={unavailableMessageLabel}
|
|
806
|
+
/>
|
|
807
|
+
)}
|
|
808
|
+
|
|
809
|
+
{messages.length === 0 && (
|
|
810
|
+
EmptyStateIndicator === DefaultEmpty
|
|
811
|
+
? <DefaultEmpty title={emptyTitle} subtitle={emptySubtitle} />
|
|
812
|
+
: <EmptyStateIndicator />
|
|
813
|
+
)}
|
|
814
|
+
|
|
815
|
+
{pendingInviteeName && (
|
|
816
|
+
<PendingInviteeNotificationComponent
|
|
817
|
+
inviteeName={pendingInviteeName}
|
|
818
|
+
label={typeof pendingInviteeLabel === 'function' ? pendingInviteeLabel(pendingInviteeName) : pendingInviteeLabel}
|
|
819
|
+
/>
|
|
820
|
+
)}
|
|
821
|
+
|
|
822
|
+
<VList
|
|
823
|
+
key={activeChannel?.cid || 'empty'}
|
|
824
|
+
ref={vlistRef}
|
|
825
|
+
shift={shiftMode}
|
|
826
|
+
onScroll={handleScroll}
|
|
827
|
+
className="ermis-message-list__vlist"
|
|
828
|
+
>
|
|
829
|
+
{messageElements}
|
|
830
|
+
</VList>
|
|
831
|
+
|
|
832
|
+
{/* Jump to latest button */}
|
|
833
|
+
{hasNewer && (
|
|
834
|
+
JumpToLatestButton === DefaultJumpToLatest
|
|
835
|
+
? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
|
|
836
|
+
: <JumpToLatestButton onClick={jumpToLatest} />
|
|
837
|
+
)}
|
|
838
|
+
</div>
|
|
839
|
+
|
|
840
|
+
{/* Typing indicator — outside message list, flows between messages and input */}
|
|
841
|
+
{showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
|
|
842
|
+
</>
|
|
537
843
|
);
|
|
538
844
|
});
|
|
539
845
|
|