@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
@@ -1,97 +1,33 @@
1
- import React, { useEffect, useState, useCallback, useMemo } from 'react';
2
- import { VList } from 'virtua';
1
+ import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react';
2
+ import { VList as _VList, type VListHandle } from 'virtua';
3
+ const VList = _VList as any;
3
4
  import type { Channel, Event, ChannelFilters } from '@ermis-network/ermis-chat-sdk';
4
- import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
5
5
  import { useChatClient } from '../hooks/useChatClient';
6
6
  import { useChannelListUpdates } from '../hooks/useChannelListUpdates';
7
7
  import { useOnlineUsers } from '../hooks/useOnlineUsers';
8
- import { replaceMentionsForPreview, buildUserMap } from '../utils';
8
+ import { getLastMessagePreview } from '../utils';
9
9
  import { useChannelRowUpdates } from '../hooks/useChannelRowUpdates';
10
10
  import { usePendingState } from '../hooks/usePendingState';
11
+ import {
12
+ SystemMessageTranslations,
13
+ SignalMessageTranslations,
14
+ } from '@ermis-network/ermis-chat-sdk';
11
15
  import { Avatar } from './Avatar';
16
+ import { useChatComponents } from '../context/ChatComponentsContext';
12
17
  import type { ChannelItemProps, ChannelListProps } from '../types';
13
18
 
14
19
  export type { ChannelListProps, ChannelItemProps } from '../types';
15
20
  import type { ChannelActionsProps } from '../types';
16
21
  import { TopicModal } from './TopicModal';
17
22
  import { DefaultChannelActions, computeDefaultActions } from './ChannelActions';
18
- import { isDirectChannel, hasTopicsEnabled } from '../channelTypeUtils';
23
+ import { FlatTopicGroupItem } from './FlatTopicGroupItem';
24
+ import { isDirectChannel, isGroupChannel, hasTopicsEnabled } from '../channelTypeUtils';
19
25
  import { canManageChannel, isPendingMember, isSkippedMember, isFriendChannel } from '../channelRoleUtils';
20
26
 
21
27
  export { DefaultChannelActions } from './ChannelActions';
22
28
  export type { ChannelAction, ChannelActionsProps } from '../types';
23
29
 
24
- /**
25
- * Get a human-readable preview string for the last message,
26
- * handling regular, system, and signal message types.
27
- */
28
- function getLastMessagePreview(
29
- channel: Channel,
30
- myUserId?: string,
31
- ): { text: string; user: string; timestamp?: string | Date } {
32
- const lastMsg = channel.state?.latestMessages?.slice(-1)[0];
33
- if (!lastMsg) return { text: '', user: '' };
34
-
35
- const timestamp = lastMsg.created_at;
36
-
37
- const msgType = lastMsg.type || 'regular';
38
- const rawText = lastMsg.text ?? '';
39
-
40
- if (msgType === 'system') {
41
- const userMap = buildUserMap(channel.state);
42
- return { text: parseSystemMessage(rawText, userMap), user: '', timestamp };
43
- }
44
-
45
- if (msgType === 'signal') {
46
- const result = parseSignalMessage(rawText, myUserId || '');
47
- return { text: result?.text || rawText, user: '', timestamp };
48
- }
49
-
50
- // Display 'Sticker' if message is a sticker
51
- if (msgType === 'sticker' || (lastMsg as Record<string, unknown>).sticker_url) {
52
- return { text: 'Sticker', user: lastMsg.user?.name || lastMsg.user_id || '', timestamp };
53
- }
54
-
55
- // Regular / other
56
- let displayText = rawText;
57
- if (!displayText && lastMsg.attachments && lastMsg.attachments.length > 0) {
58
- const att = lastMsg.attachments[0];
59
- const type = att.type || '';
60
- switch (type) {
61
- case 'image':
62
- displayText = '📷 Photo';
63
- break;
64
- case 'video':
65
- displayText = '🎬 Video';
66
- break;
67
- case 'voiceRecording':
68
- displayText = '🎤 Voice message';
69
- break;
70
- default:
71
- displayText = '📎 File';
72
- break;
73
- }
74
- if (lastMsg.attachments.length > 1) {
75
- displayText += ` +${lastMsg.attachments.length - 1}`;
76
- }
77
- }
78
30
 
79
- // Format mentions if necessary
80
- const lastMsgRecord = lastMsg as Record<string, unknown>;
81
- const mentionedUsers = lastMsgRecord.mentioned_users as string[] | undefined;
82
- const mentionedAll = lastMsgRecord.mentioned_all as boolean | undefined;
83
-
84
- if (displayText && (mentionedAll || (mentionedUsers && mentionedUsers.length > 0))) {
85
- const userMap = buildUserMap(channel.state);
86
- displayText = replaceMentionsForPreview(displayText, lastMsg as any, userMap);
87
- }
88
-
89
- return {
90
- text: displayText,
91
- user: lastMsg.user?.name || lastMsg.user_id || '',
92
- timestamp,
93
- };
94
- }
95
31
 
96
32
  /* ----------------------------------------------------------
97
33
  Memoized channel list item (exported for consumer reuse)
@@ -117,6 +53,8 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
117
53
  onAddTopic,
118
54
  onEditTopic,
119
55
  onToggleCloseTopic,
56
+ onDeleteTopic,
57
+ onTruncateChannel,
120
58
  hiddenActions,
121
59
  actionLabels,
122
60
  actionIcons,
@@ -141,8 +79,8 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
141
79
  }, [channel]);
142
80
 
143
81
  const defaultActions = useMemo(
144
- () => computeDefaultActions(channel, currentUserId, { onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons }),
145
- [channel, currentUserId, updateCount, onAddTopic, onEditTopic, onToggleCloseTopic, isBlocked, actionLabels, actionIcons],
82
+ () => computeDefaultActions(channel, currentUserId, { onAddTopic, onEditTopic, onToggleCloseTopic, onDeleteTopic, onTruncateChannel, isBlocked, actionLabels, actionIcons }),
83
+ [channel, currentUserId, updateCount, onAddTopic, onEditTopic, onToggleCloseTopic, onDeleteTopic, onTruncateChannel, isBlocked, actionLabels, actionIcons],
146
84
  );
147
85
 
148
86
  const filteredActions = useMemo(() => {
@@ -151,9 +89,30 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
151
89
  }, [defaultActions, hiddenActions]);
152
90
  const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
153
91
 
154
- const name = channel.data?.name || channel.cid;
155
- const image = channel.data?.image as string | undefined;
92
+ // For DM channels, resolve name/image from the other member if channel.data.name is missing
93
+ const resolvedNameImage = useMemo(() => {
94
+ if (channel.data?.name) {
95
+ return { name: channel.data.name as string, image: channel.data.image as string | undefined };
96
+ }
97
+ // For DM (messaging) channels, find the other member's info
98
+ if (isDirectChannel(channel) && currentUserId && channel.state?.members) {
99
+ const members = Object.values(channel.state.members) as any[];
100
+ const other = members.find((m: any) => (m.user_id || m.user?.id) !== currentUserId);
101
+ if (other) {
102
+ const otherUser = other.user || other;
103
+ return {
104
+ name: otherUser.name || otherUser.id || channel.cid,
105
+ image: otherUser.image || otherUser.avatar || otherUser.avatar_url,
106
+ };
107
+ }
108
+ }
109
+ return { name: channel.cid, image: channel.data?.image as string | undefined };
110
+ }, [channel.data?.name, channel.data?.image, channel.state?.members, currentUserId, channel.cid, updateCount]);
111
+
112
+ const name = resolvedNameImage.name;
113
+ const image = resolvedNameImage.image;
156
114
  const showUnread = hasUnread && !isActive;
115
+ const avatarClassName = isGroupChannel(channel) ? 'ermis-avatar-wrapper--group' : undefined;
157
116
 
158
117
  const timestampText = useMemo(() => {
159
118
  if (!lastMessageTimestamp) return null;
@@ -180,10 +139,15 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
180
139
  return (
181
140
  <div className={itemClass} onClick={handleClick}>
182
141
  <div className="ermis-channel-list__item-avatar-wrapper">
183
- <AvatarComponent image={image} name={name} size={40} disableLightbox />
142
+ <AvatarComponent image={image} name={name} size={45} disableLightbox className={avatarClassName} />
184
143
  {isOnline !== undefined && (
185
144
  <span className={`ermis-channel-list__online-dot ermis-channel-list__online-dot--${isOnline ? 'online' : 'offline'}`} />
186
145
  )}
146
+ {showUnread && unreadCount > 0 && (
147
+ <span className="ermis-channel-list__avatar-unread-badge">
148
+ {unreadCount > 99 ? '99+' : unreadCount}
149
+ </span>
150
+ )}
187
151
  </div>
188
152
  <div className="ermis-channel-list__item-content">
189
153
  <div className="ermis-channel-list__item-top-row">
@@ -268,6 +232,29 @@ const DefaultEmpty = React.memo(({ text }: { text?: string }) => (
268
232
  ));
269
233
  DefaultEmpty.displayName = 'DefaultEmpty';
270
234
 
235
+ const DefaultError = React.memo(({ text, onRetry }: { text?: string; onRetry?: () => void }) => (
236
+ <div className="ermis-channel-list__error">
237
+ <div className="ermis-channel-list__error-icon">
238
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
239
+ <circle cx="12" cy="12" r="10" />
240
+ <line x1="12" y1="8" x2="12" y2="12" />
241
+ <line x1="12" y1="16" x2="12.01" y2="16" />
242
+ </svg>
243
+ </div>
244
+ <div className="ermis-channel-list__error-text">{text || 'Failed to load channels'}</div>
245
+ {onRetry && (
246
+ <button className="ermis-channel-list__error-retry" onClick={onRetry}>
247
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '6px' }}>
248
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
249
+ <path d="M21 3v5h-5" />
250
+ </svg>
251
+ Retry
252
+ </button>
253
+ )}
254
+ </div>
255
+ ));
256
+ DefaultError.displayName = 'DefaultError';
257
+
271
258
  /* ----------------------------------------------------------
272
259
  Virtual Row Component to map channel and defer parsing
273
260
  ---------------------------------------------------------- */
@@ -287,13 +274,25 @@ type ChannelRowProps = {
287
274
  onAddTopic?: (channel: Channel) => void;
288
275
  onEditTopic?: (channel: Channel) => void;
289
276
  onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
277
+ onDeleteTopic?: (channel: Channel) => void;
278
+ onTruncateChannel?: (channel: Channel) => void;
290
279
  hiddenActions?: string[];
291
280
  actionLabels?: import('../types').ChannelActionLabels;
292
281
  actionIcons?: import('../types').ChannelActionIcons;
293
282
  isOnline?: boolean;
283
+ deletedMessageLabel?: React.ReactNode;
284
+ stickerMessageLabel?: React.ReactNode;
285
+ photoMessageLabel?: React.ReactNode;
286
+ videoMessageLabel?: React.ReactNode;
287
+ voiceRecordingMessageLabel?: React.ReactNode;
288
+ fileMessageLabel?: React.ReactNode;
289
+ encryptedMessageLabel?: React.ReactNode;
290
+ encryptedMessageUnavailableLabel?: React.ReactNode;
291
+ systemMessageTranslations?: SystemMessageTranslations;
292
+ signalMessageTranslations?: SignalMessageTranslations;
294
293
  };
295
294
 
296
- const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
295
+ export const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
297
296
  channel,
298
297
  isActive,
299
298
  handleSelect,
@@ -309,10 +308,22 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
309
308
  onAddTopic,
310
309
  onEditTopic,
311
310
  onToggleCloseTopic,
311
+ onDeleteTopic,
312
+ onTruncateChannel,
312
313
  hiddenActions,
313
314
  actionLabels,
314
315
  actionIcons,
315
316
  isOnline,
317
+ deletedMessageLabel,
318
+ stickerMessageLabel,
319
+ photoMessageLabel,
320
+ videoMessageLabel,
321
+ voiceRecordingMessageLabel,
322
+ fileMessageLabel,
323
+ encryptedMessageLabel,
324
+ encryptedMessageUnavailableLabel,
325
+ systemMessageTranslations,
326
+ signalMessageTranslations,
316
327
  }) => {
317
328
  // Use the new custom hook to handle all row-level realtime updates
318
329
  const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
@@ -330,15 +341,41 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
330
341
 
331
342
  // Derive last message preview computation
332
343
  const { text: rawLastMessageText, user: rawLastMessageUser, timestamp: rawLastMessageTimestamp } = useMemo(
333
- () => getLastMessagePreview(channel, currentUserId),
344
+ () =>
345
+ getLastMessagePreview(channel, currentUserId, {
346
+ deletedMessageLabel,
347
+ stickerMessageLabel,
348
+ photoMessageLabel,
349
+ videoMessageLabel,
350
+ voiceRecordingMessageLabel,
351
+ fileMessageLabel,
352
+ encryptedMessageLabel,
353
+ encryptedMessageUnavailableLabel,
354
+ systemMessageTranslations,
355
+ signalMessageTranslations,
356
+ }),
334
357
  // Recompute if latestMessage changes or we get a force update
335
358
  // eslint-disable-next-line react-hooks/exhaustive-deps
336
- [channel, channel.state?.latestMessages, updateCount]
359
+ [
360
+ channel,
361
+ channel.state?.latestMessages,
362
+ updateCount,
363
+ deletedMessageLabel,
364
+ stickerMessageLabel,
365
+ photoMessageLabel,
366
+ videoMessageLabel,
367
+ voiceRecordingMessageLabel,
368
+ fileMessageLabel,
369
+ encryptedMessageLabel,
370
+ encryptedMessageUnavailableLabel,
371
+ systemMessageTranslations,
372
+ signalMessageTranslations,
373
+ ]
337
374
  );
338
375
 
339
376
  // Hide last message preview when banned, blocked, pending or skipped
340
377
  const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageText;
341
- const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageUser;
378
+ const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped || isDirectChannel(channel)) ? '' : rawLastMessageUser;
342
379
  const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? null : rawLastMessageTimestamp;
343
380
 
344
381
  if (renderChannel) {
@@ -371,6 +408,8 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
371
408
  onAddTopic={onAddTopic}
372
409
  onEditTopic={onEditTopic}
373
410
  onToggleCloseTopic={onToggleCloseTopic}
411
+ onDeleteTopic={onDeleteTopic}
412
+ onTruncateChannel={onTruncateChannel}
374
413
  hiddenActions={hiddenActions}
375
414
  actionLabels={actionLabels}
376
415
  actionIcons={actionIcons}
@@ -380,194 +419,17 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
380
419
  });
381
420
  ChannelRow.displayName = 'ChannelRow';
382
421
 
383
- export const ChannelTopicGroup = React.memo(({
384
- channel,
385
- activeChannel,
386
- handleSelect,
387
- renderChannel,
388
- ChannelItemComponent,
389
- AvatarComponent,
390
- GeneralTopicAvatarComponent,
391
- TopicAvatarComponent,
392
- currentUserId,
393
- pendingBadgeLabel,
394
- blockedBadgeLabel,
395
- generalTopicLabel,
396
- closedTopicIcon,
397
- PinnedIconComponent,
398
- ChannelActionsComponent,
399
- onAddTopic,
400
- onEditTopic,
401
- onToggleCloseTopic,
402
- hiddenActions,
403
- actionLabels,
404
- actionIcons,
405
- }: any) => {
406
- const { updateCount } = useChannelRowUpdates(channel, currentUserId);
407
- const [isExpanded, setIsExpanded] = useState(true);
408
- const [topicUpdateCount, setTopicUpdateCount] = useState(0);
409
-
410
- useEffect(() => {
411
- const subs: { unsubscribe: () => void }[] = [];
412
- const handleUpdate = () => setTopicUpdateCount((c) => c + 1);
413
- const currentTopics = channel.state?.topics || [];
414
- currentTopics.forEach((t: Channel) => {
415
- subs.push(t.on('channel.pinned', handleUpdate));
416
- subs.push(t.on('channel.unpinned', handleUpdate));
417
- subs.push(t.on('message.new', handleUpdate));
418
- subs.push(t.on('message.deleted', handleUpdate));
419
- });
420
- return () => {
421
- subs.forEach((s) => s.unsubscribe());
422
- };
423
- }, [channel.state?.topics]);
424
-
425
- const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []);
426
-
427
- const userRole = channel.state?.members?.[currentUserId]?.channel_role;
428
- const hasTopicAddPermission = canManageChannel(userRole);
429
-
430
- const getTopicTime = (t: Channel) => {
431
- const lastMsg = t.state?.latestMessages?.slice(-1)[0];
432
- if (lastMsg?.created_at) return new Date(lastMsg.created_at).getTime();
433
- if (t.data?.last_message_at) return new Date(t.data.last_message_at as string | Date).getTime();
434
- if (t.data?.created_at) return new Date(t.data.created_at as string | Date).getTime();
435
- return 0;
436
- };
437
-
438
- const topics = useMemo(() => {
439
- const allTopics = channel.state?.topics || [];
440
- return [...allTopics].sort((a: any, b: any) => {
441
- const aPinned = a.data?.is_pinned === true;
442
- const bPinned = b.data?.is_pinned === true;
443
- if (aPinned && !bPinned) return -1;
444
- if (!aPinned && bPinned) return 1;
445
-
446
- return getTopicTime(b) - getTopicTime(a);
447
- });
448
- }, [channel.state?.topics, topicUpdateCount]);
449
- const name = channel.data?.name || channel.cid;
450
- const image = channel.data?.image as string | undefined;
451
-
452
- const GeneralAvatar = useCallback(() => (
453
- <div className="ermis-channel-list__topic-hashtag">#</div>
454
- ), []);
455
-
456
- const TopicEmojiAvatar = useCallback(({ image }: any) => {
457
- let emoji = '💬';
458
- if (image && typeof image === 'string' && image.startsWith('emoji://')) {
459
- emoji = image.replace('emoji://', '');
460
- }
461
- return <div className="ermis-channel-list__topic-hashtag">{emoji}</div>;
462
- }, []);
463
-
464
- const generalChannelProxy = useMemo(() => {
465
- return new Proxy(channel, {
466
- get(target, prop, receiver) {
467
- if (prop === 'data') {
468
- return { ...target.data, name: generalTopicLabel || 'general', is_pinned: false };
469
- }
470
- const value = Reflect.get(target, prop, receiver);
471
- return typeof value === 'function' ? value.bind(target) : value;
472
- }
473
- });
474
- }, [channel, generalTopicLabel]);
475
-
476
- const defaultActions = useMemo(
477
- () => computeDefaultActions(channel, currentUserId, { onAddTopic, actionLabels, actionIcons }),
478
- [channel, currentUserId, updateCount, onAddTopic, actionLabels, actionIcons],
479
- );
480
-
481
- const filteredActions = useMemo(() => {
482
- if (!hiddenActions || hiddenActions.length === 0) return defaultActions;
483
- return defaultActions.filter((a: any) => !hiddenActions.includes(a.id));
484
- }, [defaultActions, hiddenActions]);
485
- const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
486
-
487
- return (
488
- <div className="ermis-channel-list__topic-group">
489
- <div
490
- className={`ermis-channel-list__topic-header ${isExpanded ? 'ermis-channel-list__topic-header--expanded' : ''}`}
491
- onClick={handleToggle}
492
- >
493
- <AvatarComponent image={image} name={name} size={40} disableLightbox />
494
- <div className="ermis-channel-list__topic-header-name">{name}</div>
495
-
496
- {channel.data?.is_pinned === true && PinnedIconComponent && (
497
- <span className="ermis-channel-list__pinned-icon" title="Pinned">
498
- <PinnedIconComponent />
499
- </span>
500
- )}
501
-
502
- <div className="ermis-channel-list__topic-actions-wrapper">
503
- <ActionsComponent channel={channel} actions={filteredActions} onClose={() => { }} />
504
- </div>
505
-
506
- <svg
507
- className="ermis-channel-list__accordion-icon"
508
- width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
509
- >
510
- <polyline points="6 9 12 15 18 9"></polyline>
511
- </svg>
512
- </div>
513
-
514
- {isExpanded && (
515
- <div className="ermis-channel-list__topic-sublist">
516
- <ChannelRow
517
- channel={generalChannelProxy as any}
518
- isActive={activeChannel?.cid === channel.cid}
519
- handleSelect={handleSelect}
520
- renderChannel={renderChannel}
521
- ChannelItemComponent={ChannelItemComponent}
522
- AvatarComponent={GeneralTopicAvatarComponent || GeneralAvatar}
523
- currentUserId={currentUserId}
524
- pendingBadgeLabel={pendingBadgeLabel}
525
- blockedBadgeLabel={blockedBadgeLabel}
526
- closedTopicIcon={closedTopicIcon}
527
- PinnedIconComponent={PinnedIconComponent}
528
- ChannelActionsComponent={() => null}
529
- hiddenActions={hiddenActions}
530
- actionLabels={actionLabels}
531
- actionIcons={actionIcons}
532
- />
533
- {topics.map((topicChannel: any) => (
534
- <ChannelRow
535
- key={topicChannel.cid}
536
- channel={topicChannel}
537
- isActive={activeChannel?.cid === topicChannel.cid}
538
- handleSelect={handleSelect}
539
- renderChannel={renderChannel}
540
- ChannelItemComponent={ChannelItemComponent}
541
- AvatarComponent={TopicAvatarComponent || TopicEmojiAvatar}
542
- currentUserId={currentUserId}
543
- pendingBadgeLabel={pendingBadgeLabel}
544
- blockedBadgeLabel={blockedBadgeLabel}
545
- closedTopicIcon={closedTopicIcon}
546
- PinnedIconComponent={PinnedIconComponent}
547
- ChannelActionsComponent={ChannelActionsComponent}
548
- onEditTopic={onEditTopic}
549
- onToggleCloseTopic={onToggleCloseTopic}
550
- hiddenActions={hiddenActions}
551
- actionLabels={actionLabels}
552
- actionIcons={actionIcons}
553
- />
554
- ))}
555
- </div>
556
- )}
557
- </div>
558
- );
559
- });
560
- ChannelTopicGroup.displayName = 'ChannelTopicGroup';
561
422
 
562
423
  export const ChannelList: React.FC<ChannelListProps> = React.memo(({
563
- filters = { type: ['messaging', 'team', 'meeting'], include_pinned_messages: true } as unknown as ChannelFilters,
424
+ filters = { type: ['messaging', 'team', 'meeting'], include_hidden_messages: true } as unknown as ChannelFilters,
564
425
  sort = [],
565
- options = { message_limit: 25 } as unknown as ChannelListProps['options'],
426
+ options = { message_limit: 1 } as unknown as ChannelListProps['options'],
566
427
  renderChannel,
567
428
  onChannelSelect,
568
429
  className,
569
430
  LoadingIndicator = DefaultLoading,
570
431
  EmptyStateIndicator = DefaultEmpty,
432
+ ErrorIndicator,
571
433
  AvatarComponent = Avatar,
572
434
  ChannelItemComponent = ChannelItem,
573
435
  pendingInvitesLabel,
@@ -575,11 +437,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
575
437
  pendingBadgeLabel,
576
438
  loadingLabel,
577
439
  emptyStateLabel = 'No channels found',
440
+ errorLabel = 'Failed to load channels',
578
441
  blockedBadgeLabel = 'Blocked',
579
- ChannelTopicGroupComponent,
580
- GeneralTopicAvatarComponent,
581
- TopicAvatarComponent,
582
- generalTopicLabel = 'general',
583
442
  onAddTopic,
584
443
  TopicEmojiPickerComponent,
585
444
  closedTopicIcon,
@@ -587,18 +446,52 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
587
446
  ChannelActionsComponent,
588
447
  onEditTopic,
589
448
  onToggleCloseTopic,
449
+ onDeleteTopic,
450
+ onTruncateChannel,
590
451
  hiddenActions,
591
452
  actionLabels,
592
453
  actionIcons,
593
454
  showOnlineStatus = true,
455
+ showPendingInvites = true,
456
+ onTopicDrillDown,
457
+ maxVisibleTopics,
458
+ moreTopicsLabel,
459
+ generalTopicLabel = 'general',
460
+ TopicPillComponent,
461
+ FlatTopicGroupItemComponent,
462
+ scrollToTopOnOwnMessage = true,
463
+ deletedMessageLabel,
464
+ stickerMessageLabel,
465
+ photoMessageLabel,
466
+ videoMessageLabel,
467
+ voiceRecordingMessageLabel,
468
+ fileMessageLabel,
469
+ encryptedMessageLabel,
470
+ encryptedMessageUnavailableLabel,
471
+ systemMessageTranslations,
472
+ signalMessageTranslations,
473
+ showTopicPills = false,
594
474
  }) => {
595
475
  const { client, activeChannel, setActiveChannel } = useChatClient();
476
+ const { ChannelListErrorIndicator } = useChatComponents();
477
+
596
478
  const [channels, setChannels] = useState<Channel[]>([]);
597
479
  const [loading, setLoading] = useState(true);
480
+ const [error, setError] = useState<any>(null);
481
+
482
+ const ActualErrorIndicator = ErrorIndicator || ChannelListErrorIndicator || DefaultError;
598
483
  const [isPendingExpanded, setIsPendingExpanded] = useState(true);
599
484
  const [addingTopicForChannel, setAddingTopicForChannel] = useState<Channel | null>(null);
600
485
  const [editingTopicForChannel, setEditingTopicForChannel] = useState<Channel | null>(null);
601
486
 
487
+ // Ref for imperative scroll control on the virtualized list
488
+ const vlistRef = useRef<VListHandle>(null);
489
+
490
+ // Scroll to top when the current user sends a message
491
+ const handleOwnMessageNew = useCallback(() => {
492
+ vlistRef.current?.scrollToIndex(0);
493
+ }, []);
494
+
602
495
  const handleAddTopicClick = useCallback((channel: Channel) => {
603
496
  if (onAddTopic) {
604
497
  onAddTopic(channel);
@@ -648,7 +541,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
648
541
  const ms = ch.state?.membership as Record<string, unknown> | undefined;
649
542
  const isPending = isPendingMember(ms?.channel_role as string);
650
543
  const isSkipped = isSkippedMember(ms?.channel_role as string);
651
-
544
+
652
545
  if (isSkipped) {
653
546
  return; // Filter out completely
654
547
  }
@@ -670,10 +563,12 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
670
563
  const loadChannels = useCallback(async () => {
671
564
  try {
672
565
  setLoading(true);
566
+ setError(null);
673
567
  const result = await client.queryChannels(filters, sort, options as { message_limit?: number });
674
568
  setChannels(result);
675
569
  } catch (err) {
676
570
  console.error('Failed to load channels:', err);
571
+ setError(err);
677
572
  } finally {
678
573
  setLoading(false);
679
574
  }
@@ -684,7 +579,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
684
579
  }, [loadChannels]);
685
580
 
686
581
  // Real-time: List manipulation (move to top, add, delete)
687
- useChannelListUpdates(channels, setChannels);
582
+ useChannelListUpdates(channels, setChannels, scrollToTopOnOwnMessage ? handleOwnMessageNew : undefined);
688
583
 
689
584
  // Online status: compute set of online friend user IDs (skip if disabled)
690
585
  const onlineUsers = useOnlineUsers(showOnlineStatus ? channels : []);
@@ -714,31 +609,51 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
714
609
  onChannelSelect?.(channel);
715
610
 
716
611
  // Mark as read when user selects a channel (skip if banned, blocked, or pending)
717
- const ms = channel.state?.membership as Record<string, unknown> | undefined;
718
- const chState = channel.state as unknown as Record<string, unknown> | undefined;
612
+ const activeCh = client.activeChannels[channel.cid] || channel;
613
+ const ms = activeCh.state?.membership as Record<string, unknown> | undefined;
614
+ const chState = activeCh.state as unknown as Record<string, unknown> | undefined;
719
615
  const isBannedInChannel = Boolean(ms?.banned);
720
- const isBlockedInChannel = isDirectChannel(channel) && Boolean(ms?.blocked);
616
+ const isBlockedInChannel = isDirectChannel(activeCh) && Boolean(ms?.blocked);
721
617
  const isPending = isPendingMember(ms?.channel_role as string);
722
618
  const isSkipped = isSkippedMember(ms?.channel_role as string);
723
619
 
724
- if (!isBannedInChannel && !isBlockedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
725
- channel.markRead().catch(() => { });
726
- // Optimistically reset unread to update UI immediately
727
- if (chState) chState.unreadCount = 0;
728
- setChannels((prev) => [...prev]);
620
+ if (!isBannedInChannel && !isBlockedInChannel && !isPending && !isSkipped) {
621
+ let shouldUpdate = false;
622
+ if ((chState?.unreadCount as number) > 0) {
623
+ activeCh.markRead().catch(() => { });
624
+ // Optimistically reset unread to update UI immediately
625
+ if (chState) chState.unreadCount = 0;
626
+ shouldUpdate = true;
627
+ }
628
+
629
+ // Also optimistic update on the stale channel just in case
630
+ if (channel.state && (channel.state as any).unreadCount > 0) {
631
+ (channel.state as any).unreadCount = 0;
632
+ shouldUpdate = true;
633
+ }
634
+
635
+ if (shouldUpdate) {
636
+ setChannels((prev) => [...prev]);
637
+ }
729
638
  }
730
639
  },
731
640
  [setActiveChannel, onChannelSelect, setChannels],
732
641
  );
733
642
 
734
643
  if (loading) return <LoadingIndicator text={loadingLabel} />;
735
- if (channels.length === 0) return <EmptyStateIndicator text={emptyStateLabel} />;
644
+ if (error) return <ActualErrorIndicator text={errorLabel} onRetry={loadChannels} />;
645
+
646
+ const isEmpty = showPendingInvites
647
+ ? (pendingChannels.length === 0 && regularChannels.length === 0)
648
+ : (regularChannels.length === 0);
649
+
650
+ if (isEmpty) return <EmptyStateIndicator text={emptyStateLabel} />;
736
651
 
737
652
  return (
738
653
  <div className={`ermis-channel-list${className ? ` ${className}` : ''}`}>
739
654
  {/* VList requires its container to have a height to work. */}
740
- <VList style={{ height: '100%' }}>
741
- {pendingChannels.length > 0 && (
655
+ <VList ref={vlistRef} style={{ height: '100%' }}>
656
+ {showPendingInvites && pendingChannels.length > 0 && (
742
657
  <div
743
658
  className="ermis-channel-list__accordion-header"
744
659
  onClick={() => setIsPendingExpanded(prev => !prev)}
@@ -756,7 +671,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
756
671
  </svg>
757
672
  </div>
758
673
  )}
759
- {isPendingExpanded && pendingChannels.map((channel: Channel) => {
674
+ {showPendingInvites && isPendingExpanded && pendingChannels.map((channel: Channel) => {
760
675
  const isActive = activeChannel?.cid === channel.cid;
761
676
  return (
762
677
  <ChannelRow
@@ -773,48 +688,67 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
773
688
  closedTopicIcon={closedTopicIcon}
774
689
  PinnedIconComponent={PinnedIconComponent}
775
690
  ChannelActionsComponent={ChannelActionsComponent}
691
+ onTruncateChannel={onTruncateChannel}
776
692
  hiddenActions={hiddenActions}
777
693
  actionLabels={actionLabels}
778
694
  actionIcons={actionIcons}
779
695
  isOnline={getIsOnline(channel)}
696
+ deletedMessageLabel={deletedMessageLabel}
697
+ stickerMessageLabel={stickerMessageLabel}
698
+ photoMessageLabel={photoMessageLabel}
699
+ videoMessageLabel={videoMessageLabel}
700
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
701
+ fileMessageLabel={fileMessageLabel}
702
+ encryptedMessageLabel={encryptedMessageLabel}
703
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
704
+ systemMessageTranslations={systemMessageTranslations}
705
+ signalMessageTranslations={signalMessageTranslations}
780
706
  />
781
707
  );
782
708
  })}
783
- {pendingChannels.length > 0 && regularChannels.length > 0 && (
709
+ {/* {pendingChannels.length > 0 && regularChannels.length > 0 && (
784
710
  <div className="ermis-channel-list__accordion-header ermis-channel-list__accordion-header--static">
785
711
  <span>{channelsLabel}</span>
786
712
  </div>
787
- )}
713
+ )} */}
788
714
  {regularChannels.map((channel: Channel) => {
789
- const isActive = activeChannel?.cid === channel.cid;
715
+ const isActive = activeChannel?.cid === channel.cid ||
716
+ (activeChannel?.data?.parent_cid === channel.cid);
790
717
  const isTeamWithTopics = hasTopicsEnabled(channel);
791
718
 
792
719
  if (isTeamWithTopics) {
793
- const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
720
+ // Drill-down mode: always render flat item with topic pills + last msg
721
+ const FlatComponent = FlatTopicGroupItemComponent || FlatTopicGroupItem;
794
722
  return (
795
- <GroupComponent
723
+ <FlatComponent
796
724
  key={channel.cid}
797
725
  channel={channel}
798
- activeChannel={activeChannel}
799
- handleSelect={handleSelect}
800
- renderChannel={renderChannel}
801
- ChannelItemComponent={ChannelItemComponent}
726
+ isActive={isActive}
727
+ onDrillDown={(c) => {
728
+ handleSelect(c);
729
+ if (onTopicDrillDown) onTopicDrillDown(c);
730
+ }}
802
731
  AvatarComponent={AvatarComponent}
803
- GeneralTopicAvatarComponent={GeneralTopicAvatarComponent}
804
- TopicAvatarComponent={TopicAvatarComponent}
805
- currentUserId={client.userID}
806
- pendingBadgeLabel={pendingBadgeLabel}
807
- blockedBadgeLabel={blockedBadgeLabel}
732
+ maxVisibleTopics={maxVisibleTopics}
733
+ moreTopicsLabel={moreTopicsLabel}
808
734
  generalTopicLabel={generalTopicLabel}
809
- onAddTopic={handleAddTopicClick}
810
- closedTopicIcon={closedTopicIcon}
735
+ TopicPillComponent={TopicPillComponent}
811
736
  PinnedIconComponent={PinnedIconComponent}
812
737
  ChannelActionsComponent={ChannelActionsComponent}
813
- onEditTopic={handleEditTopicClick}
814
- onToggleCloseTopic={handleToggleCloseTopicClick}
738
+ onAddTopic={handleAddTopicClick}
739
+ onTruncateChannel={onTruncateChannel}
815
740
  hiddenActions={hiddenActions}
816
741
  actionLabels={actionLabels}
817
742
  actionIcons={actionIcons}
743
+ deletedMessageLabel={deletedMessageLabel}
744
+ stickerMessageLabel={stickerMessageLabel}
745
+ photoMessageLabel={photoMessageLabel}
746
+ videoMessageLabel={videoMessageLabel}
747
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
748
+ fileMessageLabel={fileMessageLabel}
749
+ systemMessageTranslations={systemMessageTranslations}
750
+ signalMessageTranslations={signalMessageTranslations}
751
+ showTopicPills={showTopicPills}
818
752
  />
819
753
  );
820
754
  }
@@ -837,10 +771,22 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
837
771
  onAddTopic={handleAddTopicClick}
838
772
  onEditTopic={handleEditTopicClick}
839
773
  onToggleCloseTopic={handleToggleCloseTopicClick}
774
+ onDeleteTopic={onDeleteTopic}
775
+ onTruncateChannel={onTruncateChannel}
840
776
  hiddenActions={hiddenActions}
841
777
  actionLabels={actionLabels}
842
778
  actionIcons={actionIcons}
843
779
  isOnline={getIsOnline(channel)}
780
+ deletedMessageLabel={deletedMessageLabel}
781
+ stickerMessageLabel={stickerMessageLabel}
782
+ photoMessageLabel={photoMessageLabel}
783
+ videoMessageLabel={videoMessageLabel}
784
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
785
+ fileMessageLabel={fileMessageLabel}
786
+ encryptedMessageLabel={encryptedMessageLabel}
787
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
788
+ systemMessageTranslations={systemMessageTranslations}
789
+ signalMessageTranslations={signalMessageTranslations}
844
790
  />
845
791
  );
846
792
  })}
@@ -865,4 +811,4 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
865
811
  );
866
812
  });
867
813
 
868
- ChannelList.displayName = 'ChannelList'; 'ChannelList';
814
+ ChannelList.displayName = 'ChannelList';