@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
|
@@ -36,6 +36,9 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
36
36
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
|
37
37
|
const [isRemoteMicMuted, setIsRemoteMicMuted] = useState(false);
|
|
38
38
|
const [isRemoteVideoMuted, setIsRemoteVideoMuted] = useState(false);
|
|
39
|
+
const [isAccepting, setIsAccepting] = useState(false);
|
|
40
|
+
const [isRejecting, setIsRejecting] = useState(false);
|
|
41
|
+
const [isEnding, setIsEnding] = useState(false);
|
|
39
42
|
|
|
40
43
|
// Call duration timer (C7 — exposed via context)
|
|
41
44
|
const [callDuration, setCallDuration] = useState(0);
|
|
@@ -59,9 +62,15 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
59
62
|
useEffect(() => {
|
|
60
63
|
if (callStatus === CallStatus.CONNECTED) {
|
|
61
64
|
startTimer();
|
|
65
|
+
setIsAccepting(false);
|
|
62
66
|
} else {
|
|
63
67
|
stopTimer();
|
|
64
68
|
}
|
|
69
|
+
if (!callStatus) {
|
|
70
|
+
setIsAccepting(false);
|
|
71
|
+
setIsRejecting(false);
|
|
72
|
+
setIsEnding(false);
|
|
73
|
+
}
|
|
65
74
|
return () => stopTimer();
|
|
66
75
|
}, [callStatus, startTimer, stopTimer]);
|
|
67
76
|
|
|
@@ -92,12 +101,25 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
92
101
|
node.onDeviceChange = (audio, video) => {
|
|
93
102
|
setAudioDevices(audio);
|
|
94
103
|
setVideoDevices(video);
|
|
104
|
+
const selected = node.getSelectedDevices();
|
|
105
|
+
if (selected.audioDevice) setSelectedAudioDeviceId(selected.audioDevice.deviceId);
|
|
106
|
+
else if (audio.length > 0) setSelectedAudioDeviceId(audio[0].deviceId);
|
|
107
|
+
|
|
108
|
+
if (selected.videoDevice) setSelectedVideoDeviceId(selected.videoDevice.deviceId);
|
|
109
|
+
else if (video.length > 0) setSelectedVideoDeviceId(video[0].deviceId);
|
|
95
110
|
};
|
|
111
|
+
|
|
96
112
|
node.onScreenShareChange = (isSharing: boolean) => setIsScreenSharing(isSharing);
|
|
97
113
|
|
|
98
114
|
node.getDevices().then(({ audioDevices: a, videoDevices: v }) => {
|
|
99
115
|
setAudioDevices(a);
|
|
100
116
|
setVideoDevices(v);
|
|
117
|
+
const selected = node.getSelectedDevices();
|
|
118
|
+
if (selected.audioDevice) setSelectedAudioDeviceId(selected.audioDevice.deviceId);
|
|
119
|
+
else if (a.length > 0) setSelectedAudioDeviceId(a[0].deviceId);
|
|
120
|
+
|
|
121
|
+
if (selected.videoDevice) setSelectedVideoDeviceId(selected.videoDevice.deviceId);
|
|
122
|
+
else if (v.length > 0) setSelectedVideoDeviceId(v[0].deviceId);
|
|
101
123
|
});
|
|
102
124
|
|
|
103
125
|
node.onCallStatus = (status: string | null) => {
|
|
@@ -152,35 +174,56 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
152
174
|
if (!callNode) return;
|
|
153
175
|
setCallType(type);
|
|
154
176
|
setIsIncoming(false);
|
|
155
|
-
|
|
177
|
+
|
|
178
|
+
// Tận dụng Local Cache: Phân giải thông tin đồng bộ ngay trước khi bật modal
|
|
179
|
+
callNode.prefillUserInfo(cid);
|
|
180
|
+
setCallerInfo(callNode.callerInfo);
|
|
181
|
+
setReceiverInfo(callNode.receiverInfo);
|
|
182
|
+
|
|
183
|
+
setCallStatus(CallStatus.PREPARING);
|
|
156
184
|
await callNode.createCall(type, cid);
|
|
157
185
|
// C1: Lifecycle callback — call started
|
|
158
186
|
onCallStart?.(type, cid);
|
|
159
187
|
}, [callNode, onCallStart]);
|
|
160
188
|
|
|
161
189
|
const acceptCall = useCallback(async () => {
|
|
162
|
-
if (callNode)
|
|
163
|
-
|
|
164
|
-
|
|
190
|
+
if (!callNode) return;
|
|
191
|
+
setIsAccepting(true);
|
|
192
|
+
try {
|
|
193
|
+
await callNode.acceptCall();
|
|
194
|
+
onCallAccepted?.();
|
|
195
|
+
} catch (e) {
|
|
196
|
+
setIsAccepting(false);
|
|
197
|
+
}
|
|
165
198
|
}, [callNode, onCallAccepted]);
|
|
166
199
|
|
|
167
200
|
const rejectCall = useCallback(async () => {
|
|
168
|
-
if (callNode)
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
201
|
+
if (!callNode) return;
|
|
202
|
+
setIsRejecting(true);
|
|
203
|
+
try {
|
|
204
|
+
await callNode.rejectCall();
|
|
205
|
+
setCallStatus('');
|
|
206
|
+
setIsIncoming(false);
|
|
207
|
+
onCallRejected?.();
|
|
208
|
+
} finally {
|
|
209
|
+
setIsRejecting(false);
|
|
210
|
+
}
|
|
173
211
|
}, [callNode, onCallRejected]);
|
|
174
212
|
|
|
175
213
|
const endCall = useCallback(async () => {
|
|
176
|
-
if (callNode)
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
214
|
+
if (!callNode) return;
|
|
215
|
+
setIsEnding(true);
|
|
216
|
+
try {
|
|
217
|
+
await callNode.endCall();
|
|
218
|
+
const duration = callDuration;
|
|
219
|
+
setCallStatus('');
|
|
220
|
+
setIsIncoming(false);
|
|
221
|
+
setLocalStream(null);
|
|
222
|
+
setRemoteStream(null);
|
|
223
|
+
onCallEnd?.(duration);
|
|
224
|
+
} finally {
|
|
225
|
+
setIsEnding(false);
|
|
226
|
+
}
|
|
184
227
|
}, [callNode, callDuration, onCallEnd]);
|
|
185
228
|
|
|
186
229
|
const toggleScreenShare = useCallback(async () => {
|
|
@@ -205,6 +248,14 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
205
248
|
}, [callNode]);
|
|
206
249
|
|
|
207
250
|
const clearError = useCallback(() => setErrorMessage(null), []);
|
|
251
|
+
const resetCall = useCallback(() => {
|
|
252
|
+
if (callNode) callNode.destroy();
|
|
253
|
+
setCallStatus('');
|
|
254
|
+
setErrorMessage(null);
|
|
255
|
+
setIsIncoming(false);
|
|
256
|
+
setCallDuration(0);
|
|
257
|
+
setCallType('audio');
|
|
258
|
+
}, [callNode]);
|
|
208
259
|
|
|
209
260
|
const upgradeCall = useCallback(async () => {
|
|
210
261
|
if (!callNode) return;
|
|
@@ -267,6 +318,10 @@ export const ErmisCallProvider: React.FC<ErmisCallProviderProps> = ({
|
|
|
267
318
|
isRemoteVideoMuted,
|
|
268
319
|
upgradeCall,
|
|
269
320
|
callDuration,
|
|
321
|
+
isAccepting,
|
|
322
|
+
isRejecting,
|
|
323
|
+
isEnding,
|
|
324
|
+
resetCall,
|
|
270
325
|
};
|
|
271
326
|
|
|
272
327
|
return (
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
|
2
2
|
import { useCallContext } from '../hooks/useCallContext';
|
|
3
|
-
import { Modal } from './Modal';
|
|
3
|
+
import { Modal as DefaultModal } from './Modal';
|
|
4
4
|
import { Avatar } from './Avatar';
|
|
5
|
+
import { useChatComponents } from '../context/ChatComponentsContext';
|
|
5
6
|
import { CallStatus } from '@ermis-network/ermis-chat-sdk';
|
|
6
7
|
import type { ErmisCallUIProps } from '../types';
|
|
7
8
|
|
|
@@ -82,8 +83,14 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
82
83
|
isRemoteMicMuted,
|
|
83
84
|
upgradeCall,
|
|
84
85
|
callDuration,
|
|
86
|
+
isAccepting,
|
|
87
|
+
isRejecting,
|
|
88
|
+
isEnding,
|
|
85
89
|
} = useCallContext();
|
|
86
90
|
|
|
91
|
+
const { ModalComponent } = useChatComponents();
|
|
92
|
+
const Modal = ModalComponent || DefaultModal;
|
|
93
|
+
|
|
87
94
|
const localVideoRef = useRef<HTMLVideoElement>(null);
|
|
88
95
|
const remoteVideoRef = useRef<HTMLVideoElement>(null);
|
|
89
96
|
const remoteAudioRef = useRef<HTMLAudioElement>(null);
|
|
@@ -259,6 +266,7 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
259
266
|
isVideoMuted={isVideoMuted}
|
|
260
267
|
isScreenSharing={isScreenSharing}
|
|
261
268
|
isFullscreen={isFullscreen}
|
|
269
|
+
isEnding={isEnding}
|
|
262
270
|
audioDevices={audioDevices}
|
|
263
271
|
videoDevices={videoDevices}
|
|
264
272
|
selectedAudioDeviceId={selectedAudioDeviceId}
|
|
@@ -341,15 +349,17 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
341
349
|
)}
|
|
342
350
|
|
|
343
351
|
{/* Fullscreen */}
|
|
344
|
-
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
352
|
+
{callType === 'video' && (
|
|
353
|
+
<div className="ermis-call-ui__action-group">
|
|
354
|
+
<button
|
|
355
|
+
onClick={toggleFullscreen}
|
|
356
|
+
className="ermis-call-ui__control-btn"
|
|
357
|
+
data-tooltip={isFullscreen ? exitFullscreenTitle : fullscreenTitle}
|
|
358
|
+
>
|
|
359
|
+
{isFullscreen ? <FinalExitFullscreenIcon /> : <FinalFullscreenIcon />}
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
)}
|
|
353
363
|
|
|
354
364
|
{/* Separator before end call */}
|
|
355
365
|
<div className="ermis-call-ui__controls-separator" />
|
|
@@ -357,10 +367,11 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
357
367
|
{/* End Call */}
|
|
358
368
|
<button
|
|
359
369
|
onClick={endCall}
|
|
370
|
+
disabled={isEnding}
|
|
360
371
|
className="ermis-call-ui__control-btn ermis-call-ui__control-btn--danger"
|
|
361
372
|
data-tooltip={endCallLabel}
|
|
362
373
|
>
|
|
363
|
-
<FinalPhoneIcon />
|
|
374
|
+
{isEnding ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
|
|
364
375
|
</button>
|
|
365
376
|
</div>
|
|
366
377
|
);
|
|
@@ -388,6 +399,9 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
388
399
|
endCallLabel={endCallLabel}
|
|
389
400
|
audioCallBadgeLabel={audioCallBadgeLabel}
|
|
390
401
|
videoCallBadgeLabel={videoCallBadgeLabel}
|
|
402
|
+
isAccepting={isAccepting}
|
|
403
|
+
isRejecting={isRejecting}
|
|
404
|
+
isEnding={isEnding}
|
|
391
405
|
/>
|
|
392
406
|
);
|
|
393
407
|
}
|
|
@@ -425,18 +439,24 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
425
439
|
<div className="ermis-call-ui__ringing-action">
|
|
426
440
|
<button
|
|
427
441
|
onClick={rejectCall}
|
|
442
|
+
disabled={isRejecting}
|
|
428
443
|
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
|
|
429
444
|
>
|
|
430
|
-
<FinalPhoneIcon />
|
|
445
|
+
{isRejecting ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
|
|
431
446
|
</button>
|
|
432
447
|
<span className="ermis-call-ui__action-label">{rejectCallLabel}</span>
|
|
433
448
|
</div>
|
|
434
449
|
<div className="ermis-call-ui__ringing-action">
|
|
435
450
|
<button
|
|
436
451
|
onClick={acceptCall}
|
|
452
|
+
disabled={isAccepting}
|
|
437
453
|
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--accept"
|
|
438
454
|
>
|
|
439
|
-
{
|
|
455
|
+
{isAccepting ? (
|
|
456
|
+
<div className="ermis-call-ui__spinner" />
|
|
457
|
+
) : (
|
|
458
|
+
callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />
|
|
459
|
+
)}
|
|
440
460
|
</button>
|
|
441
461
|
<span className="ermis-call-ui__action-label">{acceptCallLabel}</span>
|
|
442
462
|
</div>
|
|
@@ -445,9 +465,10 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
445
465
|
<div className="ermis-call-ui__ringing-action">
|
|
446
466
|
<button
|
|
447
467
|
onClick={endCall}
|
|
468
|
+
disabled={isEnding}
|
|
448
469
|
className="ermis-call-ui__action-circle ermis-call-ui__action-circle--reject"
|
|
449
470
|
>
|
|
450
|
-
<FinalPhoneIcon />
|
|
471
|
+
{isEnding ? <div className="ermis-call-ui__spinner" /> : <FinalPhoneIcon />}
|
|
451
472
|
</button>
|
|
452
473
|
<span className="ermis-call-ui__action-label">{endCallLabel}</span>
|
|
453
474
|
</div>
|
|
@@ -492,12 +513,14 @@ export const ErmisCallUI: React.FC<ErmisCallUIProps> = React.memo(({
|
|
|
492
513
|
className="ermis-call-ui__video-local-stream"
|
|
493
514
|
/>
|
|
494
515
|
</div>
|
|
495
|
-
{/*
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
<FinalMicOffIcon
|
|
499
|
-
|
|
500
|
-
|
|
516
|
+
{/* Call status bar: mic-muted indicator + duration timer */}
|
|
517
|
+
<div className="ermis-call-ui__video-timer">
|
|
518
|
+
{isRemoteMicMuted && (
|
|
519
|
+
<span className="ermis-call-ui__video-timer-mic"><FinalMicOffIcon /></span>
|
|
520
|
+
)}
|
|
521
|
+
<span className="ermis-call-ui__active-status-dot" />
|
|
522
|
+
<span>{formatDuration(callDuration)}</span>
|
|
523
|
+
</div>
|
|
501
524
|
{/* Glassmorphism controls overlay */}
|
|
502
525
|
<div className="ermis-call-ui__video-controls-overlay">
|
|
503
526
|
{renderControls()}
|
|
@@ -37,7 +37,7 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
|
|
|
37
37
|
const fileName = item.file?.name || item.originalAttachment?.title || 'Unknown file';
|
|
38
38
|
const fileSize = item.file?.size || item.originalAttachment?.file_size || 0;
|
|
39
39
|
|
|
40
|
-
const isHeic = item.file ? isHeicFile(item.file) :
|
|
40
|
+
const isHeic = item.file ? isHeicFile(item.file) : fileType === 'image/heic' || fileType === 'image/heif';
|
|
41
41
|
const isImage = fileType.startsWith('image/') && !isHeic;
|
|
42
42
|
const isVideo = fileType.startsWith('video/');
|
|
43
43
|
const isUploading = item.status === 'uploading';
|
|
@@ -62,17 +62,9 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
|
|
|
62
62
|
|
|
63
63
|
{/* Preview content */}
|
|
64
64
|
{isImage && previewUrl ? (
|
|
65
|
-
<img
|
|
66
|
-
className="ermis-files-preview__thumb"
|
|
67
|
-
src={previewUrl}
|
|
68
|
-
alt={fileName}
|
|
69
|
-
/>
|
|
65
|
+
<img className="ermis-files-preview__thumb" src={previewUrl} alt={fileName} />
|
|
70
66
|
) : isVideo && previewUrl ? (
|
|
71
|
-
<video
|
|
72
|
-
className="ermis-files-preview__thumb"
|
|
73
|
-
src={previewUrl}
|
|
74
|
-
muted
|
|
75
|
-
/>
|
|
67
|
+
<video className="ermis-files-preview__thumb" src={previewUrl} muted />
|
|
76
68
|
) : (
|
|
77
69
|
<div className="ermis-files-preview__file-icon">
|
|
78
70
|
<span>{getFileIcon(fileType)}</span>
|
|
@@ -88,7 +80,11 @@ export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, on
|
|
|
88
80
|
{/* Upload status overlay */}
|
|
89
81
|
{isUploading && (
|
|
90
82
|
<div className="ermis-files-preview__uploading">
|
|
91
|
-
|
|
83
|
+
{item.progress !== undefined ? (
|
|
84
|
+
<span className="ermis-files-preview__progress">{item.progress}%</span>
|
|
85
|
+
) : (
|
|
86
|
+
<span className="ermis-files-preview__spinner" />
|
|
87
|
+
)}
|
|
92
88
|
</div>
|
|
93
89
|
)}
|
|
94
90
|
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import React, { useCallback, useMemo } from 'react';
|
|
2
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
4
|
+
import { SystemMessageTranslations, SignalMessageTranslations } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
import { useTopicGroupUpdates } from '../hooks/useTopicGroupUpdates';
|
|
6
|
+
import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
|
|
7
|
+
import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
|
|
8
|
+
import type { AvatarProps, ChannelActionsProps, ChannelActionLabels, ChannelActionIcons, TopicPillProps } from '../types';
|
|
9
|
+
|
|
10
|
+
/* ----------------------------------------------------------
|
|
11
|
+
Default TopicPill – renders a single topic preview
|
|
12
|
+
---------------------------------------------------------- */
|
|
13
|
+
const DefaultTopicPill: React.FC<TopicPillProps> = React.memo(({ topic }) => {
|
|
14
|
+
const image = topic.data?.image as string | undefined;
|
|
15
|
+
|
|
16
|
+
let emoji = '💬';
|
|
17
|
+
if (image && typeof image === 'string' && image.startsWith('emoji://')) {
|
|
18
|
+
emoji = image.replace('emoji://', '');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const name = topic.data?.name || '';
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<span className="ermis-channel-list__topic-pill">
|
|
25
|
+
<span className="ermis-channel-list__topic-pill-avatar">{emoji}</span>
|
|
26
|
+
{name && <span className="ermis-channel-list__topic-pill-name">{name}</span>}
|
|
27
|
+
</span>
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
DefaultTopicPill.displayName = 'DefaultTopicPill';
|
|
31
|
+
|
|
32
|
+
/* ----------------------------------------------------------
|
|
33
|
+
FlatTopicGroupItem Props
|
|
34
|
+
---------------------------------------------------------- */
|
|
35
|
+
type FlatTopicGroupItemProps = {
|
|
36
|
+
channel: Channel;
|
|
37
|
+
isActive: boolean;
|
|
38
|
+
onDrillDown?: (channel: Channel) => void;
|
|
39
|
+
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
40
|
+
maxVisibleTopics?: number;
|
|
41
|
+
moreTopicsLabel?: string;
|
|
42
|
+
/** Label for the general pill (default: 'general') */
|
|
43
|
+
generalTopicLabel?: string;
|
|
44
|
+
TopicPillComponent?: React.ComponentType<TopicPillProps>;
|
|
45
|
+
PinnedIconComponent?: React.ComponentType;
|
|
46
|
+
ChannelActionsComponent?: React.ComponentType<ChannelActionsProps>;
|
|
47
|
+
onAddTopic?: (channel: Channel) => void;
|
|
48
|
+
onTruncateChannel?: (channel: Channel) => void;
|
|
49
|
+
hiddenActions?: string[];
|
|
50
|
+
actionLabels?: ChannelActionLabels;
|
|
51
|
+
actionIcons?: ChannelActionIcons;
|
|
52
|
+
deletedMessageLabel?: React.ReactNode;
|
|
53
|
+
stickerMessageLabel?: React.ReactNode;
|
|
54
|
+
photoMessageLabel?: React.ReactNode;
|
|
55
|
+
videoMessageLabel?: React.ReactNode;
|
|
56
|
+
voiceRecordingMessageLabel?: React.ReactNode;
|
|
57
|
+
fileMessageLabel?: React.ReactNode;
|
|
58
|
+
systemMessageTranslations?: SystemMessageTranslations;
|
|
59
|
+
signalMessageTranslations?: SignalMessageTranslations;
|
|
60
|
+
showTopicPills?: boolean;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/* ----------------------------------------------------------
|
|
64
|
+
FlatTopicGroupItem – flat channel item with topic preview
|
|
65
|
+
Shows like a normal ChannelItem (name, last msg, timestamp,
|
|
66
|
+
unread badge) plus a row of topic pills.
|
|
67
|
+
---------------------------------------------------------- */
|
|
68
|
+
export const FlatTopicGroupItem: React.FC<FlatTopicGroupItemProps> = React.memo(({
|
|
69
|
+
channel,
|
|
70
|
+
isActive,
|
|
71
|
+
onDrillDown,
|
|
72
|
+
AvatarComponent,
|
|
73
|
+
maxVisibleTopics = 3,
|
|
74
|
+
moreTopicsLabel = '...',
|
|
75
|
+
generalTopicLabel = 'general',
|
|
76
|
+
TopicPillComponent,
|
|
77
|
+
PinnedIconComponent,
|
|
78
|
+
ChannelActionsComponent,
|
|
79
|
+
onAddTopic,
|
|
80
|
+
hiddenActions,
|
|
81
|
+
actionLabels,
|
|
82
|
+
actionIcons,
|
|
83
|
+
deletedMessageLabel,
|
|
84
|
+
stickerMessageLabel,
|
|
85
|
+
photoMessageLabel,
|
|
86
|
+
videoMessageLabel,
|
|
87
|
+
voiceRecordingMessageLabel,
|
|
88
|
+
fileMessageLabel,
|
|
89
|
+
systemMessageTranslations,
|
|
90
|
+
signalMessageTranslations,
|
|
91
|
+
showTopicPills = false,
|
|
92
|
+
}) => {
|
|
93
|
+
const { client } = useChatClient();
|
|
94
|
+
const currentUserId = client.userID;
|
|
95
|
+
|
|
96
|
+
// Realtime updates for parent channel row (pin/unpin, channel.updated)
|
|
97
|
+
const { updateCount } = useChannelRowUpdates(channel, currentUserId);
|
|
98
|
+
|
|
99
|
+
// Realtime topic group data (sorted topics, aggregated unread, latest message)
|
|
100
|
+
const { topics, aggregatedUnreadCount, hasUnread, latestMessagePreview } = useTopicGroupUpdates(
|
|
101
|
+
channel,
|
|
102
|
+
currentUserId,
|
|
103
|
+
{
|
|
104
|
+
deletedMessageLabel,
|
|
105
|
+
stickerMessageLabel,
|
|
106
|
+
photoMessageLabel,
|
|
107
|
+
videoMessageLabel,
|
|
108
|
+
voiceRecordingMessageLabel,
|
|
109
|
+
fileMessageLabel,
|
|
110
|
+
systemMessageTranslations,
|
|
111
|
+
signalMessageTranslations,
|
|
112
|
+
}
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const name = channel.data?.name || channel.cid;
|
|
116
|
+
const image = channel.data?.image as string | undefined;
|
|
117
|
+
const isPinned = channel.data?.is_pinned === true;
|
|
118
|
+
// For topic groups, always show the unread badge if there are unreads.
|
|
119
|
+
// Unlike normal channels, isActive only means the user is viewing ONE topic,
|
|
120
|
+
// not that they've read ALL topics. The aggregatedUnreadCount already correctly
|
|
121
|
+
// reflects only the truly unread topics.
|
|
122
|
+
const showUnread = hasUnread;
|
|
123
|
+
|
|
124
|
+
// Latest message data from the aggregated preview
|
|
125
|
+
const lastMessageText = latestMessagePreview?.text || '';
|
|
126
|
+
const lastMessageUser = latestMessagePreview?.user || '';
|
|
127
|
+
const lastMessageTimestamp = latestMessagePreview?.timestamp;
|
|
128
|
+
const lastMessageSourceName = latestMessagePreview?.sourceName || null;
|
|
129
|
+
|
|
130
|
+
const timestampText = useMemo(() => {
|
|
131
|
+
if (!lastMessageTimestamp) return null;
|
|
132
|
+
const d = new Date(lastMessageTimestamp);
|
|
133
|
+
if (isNaN(d.getTime())) return null;
|
|
134
|
+
const today = new Date();
|
|
135
|
+
const isToday = d.toDateString() === today.toDateString();
|
|
136
|
+
return isToday
|
|
137
|
+
? d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
|
138
|
+
: d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
139
|
+
}, [lastMessageTimestamp]);
|
|
140
|
+
|
|
141
|
+
// Visible topic pills: general pill + sub-topic pills (capped at maxVisibleTopics)
|
|
142
|
+
const visibleTopics = useMemo(
|
|
143
|
+
() => topics.slice(0, Math.max(0, maxVisibleTopics - 1)),
|
|
144
|
+
[topics, maxVisibleTopics],
|
|
145
|
+
);
|
|
146
|
+
const hasOverflow = (topics.length + 1) > maxVisibleTopics; // +1 for general pill
|
|
147
|
+
|
|
148
|
+
// Actions menu (pin, create topic, delete, leave)
|
|
149
|
+
const defaultActions = useMemo(
|
|
150
|
+
() => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
|
|
151
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
152
|
+
[channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
|
|
153
|
+
);
|
|
154
|
+
const filteredActions = useMemo(() => {
|
|
155
|
+
if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
|
|
156
|
+
return defaultActions.filter((a) => !hiddenActions.includes(a.id));
|
|
157
|
+
}, [defaultActions, hiddenActions]);
|
|
158
|
+
const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
|
|
159
|
+
|
|
160
|
+
const Pill = TopicPillComponent || DefaultTopicPill;
|
|
161
|
+
|
|
162
|
+
const handleClick = useCallback(() => {
|
|
163
|
+
if (onDrillDown) onDrillDown(channel);
|
|
164
|
+
}, [channel, onDrillDown]);
|
|
165
|
+
|
|
166
|
+
const itemClass = [
|
|
167
|
+
'ermis-channel-list__item',
|
|
168
|
+
'ermis-channel-list__item--topic-group',
|
|
169
|
+
isActive ? 'ermis-channel-list__item--active' : '',
|
|
170
|
+
showUnread ? 'ermis-channel-list__item--unread' : '',
|
|
171
|
+
].filter(Boolean).join(' ');
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<div className={itemClass} onClick={handleClick}>
|
|
175
|
+
<div className="ermis-channel-list__item-avatar-wrapper">
|
|
176
|
+
<AvatarComponent image={image} name={name} size={45} disableLightbox className="ermis-avatar-wrapper--group" />
|
|
177
|
+
{showUnread && aggregatedUnreadCount > 0 && (
|
|
178
|
+
<span className="ermis-channel-list__avatar-unread-badge">
|
|
179
|
+
{aggregatedUnreadCount > 99 ? '99+' : aggregatedUnreadCount}
|
|
180
|
+
</span>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
<div className="ermis-channel-list__item-content">
|
|
184
|
+
{/* Row 1: name + pinned + timestamp */}
|
|
185
|
+
<div className="ermis-channel-list__item-top-row">
|
|
186
|
+
<div className="ermis-channel-list__item-name">{name}</div>
|
|
187
|
+
{isPinned && PinnedIconComponent && (
|
|
188
|
+
<span className="ermis-channel-list__pinned-icon" title="Pinned">
|
|
189
|
+
<PinnedIconComponent />
|
|
190
|
+
</span>
|
|
191
|
+
)}
|
|
192
|
+
{timestampText && <div className="ermis-channel-list__item-timestamp">{timestampText}</div>}
|
|
193
|
+
</div>
|
|
194
|
+
{/* Row 2: last message + unread badge */}
|
|
195
|
+
<div className="ermis-channel-list__item-bottom-row">
|
|
196
|
+
{lastMessageText && (
|
|
197
|
+
<div className="ermis-channel-list__item-last-message">
|
|
198
|
+
{lastMessageSourceName && (
|
|
199
|
+
<span className="ermis-channel-list__item-last-message-source">
|
|
200
|
+
#{lastMessageSourceName} · {' '}
|
|
201
|
+
</span>
|
|
202
|
+
)}
|
|
203
|
+
{lastMessageUser && (
|
|
204
|
+
<span className="ermis-channel-list__item-last-message-user">
|
|
205
|
+
{lastMessageUser}:{' '}
|
|
206
|
+
</span>
|
|
207
|
+
)}
|
|
208
|
+
<span>{lastMessageText}</span>
|
|
209
|
+
</div>
|
|
210
|
+
)}
|
|
211
|
+
<div className="ermis-channel-list__item-badges">
|
|
212
|
+
{showUnread && aggregatedUnreadCount > 0 && (
|
|
213
|
+
<span className="ermis-channel-list__unread-badge">
|
|
214
|
+
{aggregatedUnreadCount > 99 ? '99+' : aggregatedUnreadCount}
|
|
215
|
+
</span>
|
|
216
|
+
)}
|
|
217
|
+
</div>
|
|
218
|
+
</div>
|
|
219
|
+
{/* Row 3: topic pills — always visible (at least general pill) */}
|
|
220
|
+
<div className="ermis-channel-list__item-topics-row">
|
|
221
|
+
{showTopicPills && (
|
|
222
|
+
<div className="ermis-channel-list__topic-pills">
|
|
223
|
+
<span className="ermis-channel-list__topic-pill">
|
|
224
|
+
<span className="ermis-channel-list__topic-pill-avatar">#</span>
|
|
225
|
+
<span className="ermis-channel-list__topic-pill-name">{generalTopicLabel}</span>
|
|
226
|
+
</span>
|
|
227
|
+
{visibleTopics.map((topic: Channel) => (
|
|
228
|
+
<Pill key={topic.cid} topic={topic} />
|
|
229
|
+
))}
|
|
230
|
+
{hasOverflow && (
|
|
231
|
+
<span className="ermis-channel-list__topic-overflow">{moreTopicsLabel}</span>
|
|
232
|
+
)}
|
|
233
|
+
</div>
|
|
234
|
+
)}
|
|
235
|
+
</div>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="ermis-channel-list__item-actions-wrapper">
|
|
238
|
+
<ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
|
|
239
|
+
</div>
|
|
240
|
+
</div>
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
FlatTopicGroupItem.displayName = 'FlatTopicGroupItem';
|