@ermis-network/ermis-chat-react 2.0.0 → 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 +5087 -11279
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +632 -152
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +273 -9
- package/dist/index.d.ts +273 -9
- package/dist/index.mjs +5085 -11295
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/components/Channel.tsx +0 -3
- package/src/components/ChannelActions.tsx +6 -1
- package/src/components/ChannelHeader.tsx +8 -32
- package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
- package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
- package/src/components/ChannelList.tsx +72 -13
- package/src/components/CreateChannelModal.tsx +131 -12
- package/src/components/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +27 -16
- package/src/components/ForwardMessageModal.tsx +11 -3
- package/src/components/MediaLightbox.tsx +444 -304
- package/src/components/MessageActionsBox.tsx +2 -0
- package/src/components/MessageInput.tsx +41 -12
- package/src/components/MessageItem.tsx +70 -25
- package/src/components/MessageQuickReactions.tsx +131 -128
- package/src/components/MessageReactions.tsx +47 -2
- package/src/components/MessageRenderers.tsx +1030 -433
- package/src/components/PinnedMessages.tsx +40 -12
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +20 -5
- package/src/components/TypingIndicator.tsx +3 -3
- package/src/components/UserPicker.tsx +26 -25
- package/src/components/VirtualMessageList.tsx +345 -125
- package/src/context/ChatProvider.tsx +27 -1
- package/src/hooks/useChannelListUpdates.ts +22 -1
- package/src/hooks/useChannelMessages.ts +338 -51
- package/src/hooks/useChannelRowUpdates.ts +18 -6
- package/src/hooks/useChatUser.ts +9 -1
- 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 +210 -13
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -6
- package/src/hooks/useMessageActions.ts +14 -8
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useTopicGroupUpdates.ts +49 -11
- package/src/index.ts +23 -0
- package/src/messageTypeUtils.ts +14 -0
- package/src/styles/_channel-info.css +9 -0
- package/src/styles/_channel-list.css +37 -14
- package/src/styles/_media-lightbox.css +36 -3
- package/src/styles/_message-bubble.css +381 -41
- package/src/styles/_message-input.css +8 -0
- package/src/styles/_message-list.css +67 -10
- package/src/styles/_message-quick-reactions.css +101 -59
- package/src/styles/_message-reactions.css +18 -32
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +5 -5
- package/src/styles/_typing-indicator.css +23 -13
- package/src/styles/index.css +1 -0
- package/src/types.ts +115 -1
- package/src/utils/avatarColors.ts +1 -1
- package/src/utils.ts +38 -18
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
|
|
1
|
+
import React, { useState, useRef, useCallback, useMemo, useEffect, useLayoutEffect } from 'react';
|
|
2
2
|
import { VList as _VList, type VListHandle } from 'virtua';
|
|
3
3
|
|
|
4
4
|
// Workaround for React 19 JSX element type mismatch with virtua's VList
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
defaultMessageRenderers,
|
|
22
22
|
type MessageBubbleProps,
|
|
23
23
|
} from './MessageRenderers';
|
|
24
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
24
25
|
import { getDateKey, formatDateLabel, getMessageUserId, formatReadTimestamp } from '../utils';
|
|
25
26
|
import { QuotedMessagePreview } from './QuotedMessagePreview';
|
|
26
27
|
import { PinnedMessages } from './PinnedMessages';
|
|
@@ -44,6 +45,20 @@ const DefaultDateSeparator: React.FC<{ label: string }> = React.memo(({ label })
|
|
|
44
45
|
));
|
|
45
46
|
(DefaultDateSeparator as any).displayName = 'DefaultDateSeparator';
|
|
46
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
|
+
|
|
47
62
|
const DefaultJumpToLatest = React.memo(({ onClick, label = '↓ Jump to latest' }: any) => (
|
|
48
63
|
<button className="ermis-message-list__jump-latest" onClick={onClick}>
|
|
49
64
|
{label}
|
|
@@ -153,6 +168,12 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
153
168
|
collapseLabel,
|
|
154
169
|
unpinLabel,
|
|
155
170
|
stickerLabel,
|
|
171
|
+
attachmentLabel = 'Attachment',
|
|
172
|
+
unavailableMessageLabel = 'Message unavailable',
|
|
173
|
+
encryptedMessageLabel,
|
|
174
|
+
encryptedMessageFailedLabel,
|
|
175
|
+
encryptedMessageDecryptingLabel,
|
|
176
|
+
encryptedMessageUnavailableLabel,
|
|
156
177
|
typingIndicatorLabel,
|
|
157
178
|
deletedMessageLabel = 'This message was deleted',
|
|
158
179
|
systemMessageTranslations,
|
|
@@ -246,8 +267,6 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
246
267
|
} as any);
|
|
247
268
|
}
|
|
248
269
|
|
|
249
|
-
// Re-watch to get full fresh state from server
|
|
250
|
-
activeChannel.watch().catch(() => {});
|
|
251
270
|
} catch (e: any) {
|
|
252
271
|
console.error('Error accepting invite', e);
|
|
253
272
|
}
|
|
@@ -273,11 +292,13 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
273
292
|
}
|
|
274
293
|
}, [activeChannel, setActiveChannel]);
|
|
275
294
|
|
|
295
|
+
const elementsCountRef = useRef(0);
|
|
296
|
+
|
|
276
297
|
const scrollToBottom = useCallback((smooth = false, attempts = 0) => {
|
|
277
298
|
const handle = vlistRef.current;
|
|
278
299
|
if (!handle) return;
|
|
279
300
|
|
|
280
|
-
const count =
|
|
301
|
+
const count = elementsCountRef.current;
|
|
281
302
|
if (count === 0) return;
|
|
282
303
|
|
|
283
304
|
// Ensure virtua has measured the viewport via ResizeObserver.
|
|
@@ -287,11 +308,26 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
287
308
|
return;
|
|
288
309
|
}
|
|
289
310
|
|
|
311
|
+
if (!smooth && handle.scrollSize > handle.viewportSize) {
|
|
312
|
+
handle.scrollTo(Math.max(0, handle.scrollSize - handle.viewportSize));
|
|
313
|
+
}
|
|
290
314
|
handle.scrollToIndex(count - 1, { align: 'end', smooth });
|
|
291
315
|
}, []);
|
|
292
316
|
|
|
293
317
|
// Shared guard: skip scroll-triggered loads during jump transitions
|
|
294
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
|
+
}, []);
|
|
295
331
|
|
|
296
332
|
/* ---------- Hooks ---------- */
|
|
297
333
|
const {
|
|
@@ -305,9 +341,23 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
305
341
|
vlistRef,
|
|
306
342
|
messagesRef,
|
|
307
343
|
jumpingRef,
|
|
344
|
+
scrollLoadLockRef,
|
|
308
345
|
loadMoreLimit,
|
|
309
346
|
});
|
|
310
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
|
+
|
|
311
361
|
const { highlightedId, scrollToMessage, jumpToLatest } = useScrollToMessage({
|
|
312
362
|
vlistRef,
|
|
313
363
|
messagesRef,
|
|
@@ -328,6 +378,8 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
328
378
|
|
|
329
379
|
useChannelMessages({
|
|
330
380
|
scrollToBottom,
|
|
381
|
+
isNearBottom,
|
|
382
|
+
holdScrollLoadLock,
|
|
331
383
|
jumpingRef,
|
|
332
384
|
isAtBottomRef,
|
|
333
385
|
onChannelSwitch: useCallback(() => {
|
|
@@ -340,6 +392,34 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
340
392
|
containerRef,
|
|
341
393
|
});
|
|
342
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
|
+
|
|
343
423
|
const hasOverlay = Boolean(isClosedTopic || isPending || isBanned || isBlocked || isSkipped);
|
|
344
424
|
const prevOverlayRef = useRef(hasOverlay);
|
|
345
425
|
|
|
@@ -383,112 +463,245 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
383
463
|
|
|
384
464
|
/* ---------- Memoized message elements ---------- */
|
|
385
465
|
const messageElements = useMemo(() => {
|
|
386
|
-
|
|
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) => {
|
|
387
481
|
const isOwnMessage =
|
|
388
482
|
message.user_id === currentUserId || message.user?.id === currentUserId;
|
|
389
|
-
const messageType = (
|
|
483
|
+
const messageType = (
|
|
484
|
+
isStickerMessage(message) ? 'sticker' : (message.type || 'regular')
|
|
485
|
+
) as MessageLabel;
|
|
390
486
|
|
|
391
487
|
// Date separator
|
|
392
488
|
const prevMsg = index > 0 ? messages[index - 1] : null;
|
|
393
489
|
const showDateSeparator =
|
|
394
490
|
!prevMsg || getDateKey(message.created_at) !== getDateKey(prevMsg.created_at);
|
|
395
|
-
const dateSeparator = showDateSeparator ? (
|
|
396
|
-
<DateSeparatorComponent label={formatDateLabel(message.created_at, dateLocale)} />
|
|
397
|
-
) : null;
|
|
398
|
-
|
|
399
|
-
if (renderMessage) {
|
|
400
|
-
return (
|
|
401
|
-
<div key={message.id || `msg-${index}`}>
|
|
402
|
-
{dateSeparator}
|
|
403
|
-
<div>{renderMessage(message, isOwnMessage)}</div>
|
|
404
|
-
</div>
|
|
405
|
-
);
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
if (messageType === 'system') {
|
|
409
|
-
return (
|
|
410
|
-
<div key={message.id || `msg-${index}`}>
|
|
411
|
-
{dateSeparator}
|
|
412
|
-
<SystemMessageItemComponent
|
|
413
|
-
message={message}
|
|
414
|
-
isOwnMessage={isOwnMessage}
|
|
415
|
-
SystemRenderer={renderers.system}
|
|
416
|
-
systemMessageTranslations={systemMessageTranslations}
|
|
417
|
-
/>
|
|
418
|
-
</div>
|
|
419
|
-
);
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
// Message grouping
|
|
423
491
|
const prevType = (prevMsg?.type || 'regular') as MessageLabel;
|
|
424
|
-
const
|
|
425
|
-
|
|
426
|
-
|
|
492
|
+
const prevTimeGap = prevMsg
|
|
493
|
+
? Math.abs(getTimestamp(message.created_at) - getTimestamp(prevMsg.created_at)) > TIME_GAP_THRESHOLD_MS
|
|
494
|
+
: false;
|
|
427
495
|
const isFirstInGroup =
|
|
428
496
|
showDateSeparator ||
|
|
429
497
|
!prevMsg ||
|
|
430
498
|
prevType === 'system' ||
|
|
431
499
|
prevType === 'signal' ||
|
|
432
500
|
getMessageUserId(prevMsg) !== getMessageUserId(message) ||
|
|
433
|
-
|
|
434
|
-
|
|
501
|
+
prevTimeGap;
|
|
435
502
|
const nextMsg = index < messages.length - 1 ? messages[index + 1] : null;
|
|
436
503
|
const nextType = (nextMsg?.type || 'regular') as MessageLabel;
|
|
437
504
|
const nextShowDateSeparator = nextMsg
|
|
438
505
|
? getDateKey(nextMsg.created_at) !== getDateKey(message.created_at)
|
|
439
506
|
: false;
|
|
440
|
-
|
|
441
507
|
const validReaders = message.id && readByMap[message.id] ? readByMap[message.id].filter(r => r.id !== getMessageUserId(message)) : [];
|
|
442
508
|
const hasReaders = showReadReceipts && validReaders.length > 0;
|
|
443
|
-
|
|
509
|
+
const nextTimeGap = nextMsg
|
|
510
|
+
? Math.abs(getTimestamp(nextMsg.created_at) - getTimestamp(message.created_at)) > TIME_GAP_THRESHOLD_MS
|
|
511
|
+
: false;
|
|
444
512
|
const isLastInGroup =
|
|
445
513
|
!nextMsg ||
|
|
446
514
|
nextShowDateSeparator ||
|
|
447
515
|
nextType === 'system' ||
|
|
448
516
|
nextType === 'signal' ||
|
|
449
517
|
getMessageUserId(nextMsg) !== getMessageUserId(message) ||
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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}
|
|
487
556
|
/>
|
|
488
|
-
|
|
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
|
+
})()}
|
|
489
696
|
</div>
|
|
490
697
|
);
|
|
491
|
-
|
|
698
|
+
|
|
699
|
+
i = j;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
elementsCountRef.current = elements.length;
|
|
704
|
+
return elements;
|
|
492
705
|
}, [
|
|
493
706
|
messages,
|
|
494
707
|
currentUserId,
|
|
@@ -513,6 +726,9 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
513
726
|
onMentionClick,
|
|
514
727
|
onUserNameClick,
|
|
515
728
|
onAddReactionClick,
|
|
729
|
+
encryptedMessageLabel,
|
|
730
|
+
encryptedMessageFailedLabel,
|
|
731
|
+
encryptedMessageDecryptingLabel,
|
|
516
732
|
]);
|
|
517
733
|
|
|
518
734
|
if (isBanned || isBlocked) {
|
|
@@ -574,52 +790,56 @@ export const VirtualMessageList: React.FC<MessageListProps> = React.memo(({
|
|
|
574
790
|
}
|
|
575
791
|
|
|
576
792
|
return (
|
|
577
|
-
|
|
578
|
-
{
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
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>
|
|
615
839
|
|
|
616
|
-
{/*
|
|
617
|
-
{
|
|
618
|
-
|
|
619
|
-
? <DefaultJumpToLatest onClick={jumpToLatest} label={jumpToLatestLabel} />
|
|
620
|
-
: <JumpToLatestButton onClick={jumpToLatest} />
|
|
621
|
-
)}
|
|
622
|
-
</div>
|
|
840
|
+
{/* Typing indicator — outside message list, flows between messages and input */}
|
|
841
|
+
{showTypingIndicator && <TypingIndicatorComponent typingIndicatorLabel={typingIndicatorLabel} />}
|
|
842
|
+
</>
|
|
623
843
|
);
|
|
624
844
|
});
|
|
625
845
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import React, { createContext, useState, useCallback, useRef } from 'react';
|
|
1
|
+
import React, { createContext, useState, useCallback, useRef, useMemo } from 'react';
|
|
2
2
|
import type { Channel, FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
3
|
import type { Theme, ChatContextValue, ChatProviderProps, ReadStateEntry } from '../types';
|
|
4
4
|
import { ErmisCallProvider } from '../components/ErmisCallProvider';
|
|
@@ -40,6 +40,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
40
40
|
const activeChannel = activeChannelRaw;
|
|
41
41
|
const activeChannelCidRef = useRef<string | null>(null);
|
|
42
42
|
|
|
43
|
+
// In-memory draft storage — Map<cid, { html: string; files: any[] }>
|
|
44
|
+
// O(1) lookup/insert/delete, bounded by number of visited channels per session
|
|
45
|
+
const draftsRef = useRef<Map<string, { html: string; files: any[] }>>(new Map());
|
|
46
|
+
|
|
43
47
|
const setActiveChannel = useCallback((channel: Channel | null) => {
|
|
44
48
|
const newCid = channel?.cid || null;
|
|
45
49
|
if (activeChannelCidRef.current === newCid) return;
|
|
@@ -59,6 +63,25 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
59
63
|
}
|
|
60
64
|
}, [activeChannel]);
|
|
61
65
|
|
|
66
|
+
/** Save a draft message (innerHTML and files) for a specific channel */
|
|
67
|
+
const setDraft = useCallback((cid: string, draft: { html: string; files: any[] }) => {
|
|
68
|
+
if ((draft.html && draft.html.trim()) || (draft.files && draft.files.length > 0)) {
|
|
69
|
+
draftsRef.current.set(cid, draft);
|
|
70
|
+
} else {
|
|
71
|
+
draftsRef.current.delete(cid);
|
|
72
|
+
}
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
/** Retrieve the saved draft for a specific channel */
|
|
76
|
+
const getDraft = useCallback((cid: string): { html: string; files: any[] } | undefined => {
|
|
77
|
+
return draftsRef.current.get(cid);
|
|
78
|
+
}, []);
|
|
79
|
+
|
|
80
|
+
/** Clear all saved drafts (e.g. on logout) */
|
|
81
|
+
const clearAllDrafts = useCallback(() => {
|
|
82
|
+
draftsRef.current.clear();
|
|
83
|
+
}, []);
|
|
84
|
+
|
|
62
85
|
const value: ChatContextValue = {
|
|
63
86
|
client,
|
|
64
87
|
activeChannel,
|
|
@@ -79,6 +102,9 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({
|
|
|
79
102
|
jumpToMessageId,
|
|
80
103
|
setJumpToMessageId,
|
|
81
104
|
enableCall,
|
|
105
|
+
setDraft,
|
|
106
|
+
getDraft,
|
|
107
|
+
clearAllDrafts,
|
|
82
108
|
};
|
|
83
109
|
|
|
84
110
|
const CallUIView = CallUIComponent ? <CallUIComponent /> : (
|