@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.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. 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
- setCallStatus(CallStatus.RINGING);
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) await callNode.acceptCall();
163
- // C1: Lifecycle callback — call accepted
164
- onCallAccepted?.();
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) await callNode.rejectCall();
169
- setCallStatus('');
170
- setIsIncoming(false);
171
- // C1: Lifecycle callback — call rejected
172
- onCallRejected?.();
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) await callNode.endCall();
177
- // C1: Lifecycle callback — call ended (capture duration before reset)
178
- const duration = callDuration;
179
- setCallStatus('');
180
- setIsIncoming(false);
181
- setLocalStream(null);
182
- setRemoteStream(null);
183
- onCallEnd?.(duration);
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
- <div className="ermis-call-ui__action-group">
345
- <button
346
- onClick={toggleFullscreen}
347
- className="ermis-call-ui__control-btn"
348
- data-tooltip={isFullscreen ? exitFullscreenTitle : fullscreenTitle}
349
- >
350
- {isFullscreen ? <FinalExitFullscreenIcon /> : <FinalFullscreenIcon />}
351
- </button>
352
- </div>
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
- {callType === 'video' ? <FinalVideoIcon /> : <FinalPhoneIcon />}
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
- {/* Remote mic muted indicator */}
496
- {isRemoteMicMuted && (
497
- <div className="ermis-call-ui__remote-muted-badge">
498
- <FinalMicOffIcon />
499
- </div>
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) : (fileType === 'image/heic' || fileType === 'image/heif');
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
- <span className="ermis-files-preview__spinner" />
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';