@ermis-network/ermis-chat-react 1.0.8 → 2.0.0

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 (99) hide show
  1. package/dist/index.cjs +15295 -4209
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.css +701 -195
  4. package/dist/index.css.map +1 -1
  5. package/dist/index.d.mts +862 -94
  6. package/dist/index.d.ts +862 -94
  7. package/dist/index.mjs +15246 -4186
  8. package/dist/index.mjs.map +1 -1
  9. package/package.json +9 -4
  10. package/src/channelTypeUtils.ts +1 -1
  11. package/src/components/Avatar.tsx +2 -1
  12. package/src/components/Channel.tsx +6 -2
  13. package/src/components/ChannelActions.tsx +61 -2
  14. package/src/components/ChannelHeader.tsx +19 -5
  15. package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
  16. package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
  17. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  18. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  19. package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
  21. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  23. package/src/components/ChannelInfo/States.tsx +1 -1
  24. package/src/components/ChannelInfo/index.ts +3 -0
  25. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
  26. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  27. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  28. package/src/components/ChannelList.tsx +177 -290
  29. package/src/components/CreateChannelModal.tsx +166 -88
  30. package/src/components/Dropdown.tsx +1 -16
  31. package/src/components/EditPreview.tsx +1 -0
  32. package/src/components/ErmisCallProvider.tsx +72 -17
  33. package/src/components/ErmisCallUI.tsx +43 -20
  34. package/src/components/FlatTopicGroupItem.tsx +232 -0
  35. package/src/components/ForwardMessageModal.tsx +31 -77
  36. package/src/components/MediaLightbox.tsx +62 -40
  37. package/src/components/MentionSuggestions.tsx +47 -35
  38. package/src/components/MessageActionsBox.tsx +4 -1
  39. package/src/components/MessageInput.tsx +137 -16
  40. package/src/components/MessageInputDefaults.tsx +127 -1
  41. package/src/components/MessageItem.tsx +93 -26
  42. package/src/components/MessageQuickReactions.tsx +153 -26
  43. package/src/components/MessageReactions.tsx +2 -1
  44. package/src/components/MessageRenderers.tsx +111 -39
  45. package/src/components/Panel.tsx +1 -14
  46. package/src/components/PinnedMessages.tsx +17 -5
  47. package/src/components/PreviewOverlay.tsx +24 -0
  48. package/src/components/ReadReceipts.tsx +2 -1
  49. package/src/components/TopicList.tsx +221 -0
  50. package/src/components/TopicModal.tsx +4 -1
  51. package/src/components/TypingIndicator.tsx +14 -5
  52. package/src/components/UserPicker.tsx +87 -10
  53. package/src/components/VirtualMessageList.tsx +106 -20
  54. package/src/context/ChatComponentsContext.tsx +14 -0
  55. package/src/context/ChatProvider.tsx +18 -14
  56. package/src/context/ErmisCallContext.tsx +4 -0
  57. package/src/hooks/useChannelCapabilities.ts +7 -4
  58. package/src/hooks/useChannelData.ts +10 -3
  59. package/src/hooks/useChannelListUpdates.ts +72 -20
  60. package/src/hooks/useChannelMessages.ts +72 -10
  61. package/src/hooks/useChannelRowUpdates.ts +24 -5
  62. package/src/hooks/useChatUser.ts +31 -0
  63. package/src/hooks/useContactChannels.ts +45 -0
  64. package/src/hooks/useContactCount.ts +50 -0
  65. package/src/hooks/useDownloadHandler.ts +36 -0
  66. package/src/hooks/useDragAndDrop.ts +79 -0
  67. package/src/hooks/useForwardMessage.ts +112 -0
  68. package/src/hooks/useInviteChannels.ts +88 -0
  69. package/src/hooks/useInviteCount.ts +104 -0
  70. package/src/hooks/useMentions.ts +0 -1
  71. package/src/hooks/useMessageActions.ts +13 -10
  72. package/src/hooks/usePendingState.ts +21 -4
  73. package/src/hooks/usePreviewState.ts +69 -0
  74. package/src/hooks/useStickerPicker.ts +62 -0
  75. package/src/hooks/useTopicGroupUpdates.ts +197 -0
  76. package/src/index.ts +56 -6
  77. package/src/messageTypeUtils.ts +13 -1
  78. package/src/styles/_base.css +0 -1
  79. package/src/styles/_call-ui.css +59 -2
  80. package/src/styles/_channel-info.css +41 -4
  81. package/src/styles/_channel-list.css +97 -57
  82. package/src/styles/_create-channel-modal.css +10 -0
  83. package/src/styles/_forward-modal.css +16 -1
  84. package/src/styles/_media-lightbox.css +32 -0
  85. package/src/styles/_mentions.css +1 -1
  86. package/src/styles/_message-actions.css +3 -4
  87. package/src/styles/_message-bubble.css +286 -107
  88. package/src/styles/_message-input.css +131 -0
  89. package/src/styles/_message-list.css +33 -17
  90. package/src/styles/_message-quick-reactions.css +40 -9
  91. package/src/styles/_message-reactions.css +4 -0
  92. package/src/styles/_modal.css +2 -1
  93. package/src/styles/_preview-overlay.css +38 -0
  94. package/src/styles/_tokens.css +17 -15
  95. package/src/styles/_typing-indicator.css +7 -1
  96. package/src/styles/index.css +1 -0
  97. package/src/types.ts +362 -14
  98. package/src/utils/avatarColors.ts +48 -0
  99. package/src/utils.ts +193 -10
@@ -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
30
 
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
-
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(() => {
@@ -154,6 +92,7 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
154
92
  const name = channel.data?.name || channel.cid;
155
93
  const image = channel.data?.image as string | undefined;
156
94
  const showUnread = hasUnread && !isActive;
95
+ const avatarClassName = isGroupChannel(channel) ? 'ermis-avatar-wrapper--group' : undefined;
157
96
 
158
97
  const timestampText = useMemo(() => {
159
98
  if (!lastMessageTimestamp) return null;
@@ -180,7 +119,7 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
180
119
  return (
181
120
  <div className={itemClass} onClick={handleClick}>
182
121
  <div className="ermis-channel-list__item-avatar-wrapper">
183
- <AvatarComponent image={image} name={name} size={40} disableLightbox />
122
+ <AvatarComponent image={image} name={name} size={40} disableLightbox className={avatarClassName} />
184
123
  {isOnline !== undefined && (
185
124
  <span className={`ermis-channel-list__online-dot ermis-channel-list__online-dot--${isOnline ? 'online' : 'offline'}`} />
186
125
  )}
@@ -268,6 +207,29 @@ const DefaultEmpty = React.memo(({ text }: { text?: string }) => (
268
207
  ));
269
208
  DefaultEmpty.displayName = 'DefaultEmpty';
270
209
 
210
+ const DefaultError = React.memo(({ text, onRetry }: { text?: string; onRetry?: () => void }) => (
211
+ <div className="ermis-channel-list__error">
212
+ <div className="ermis-channel-list__error-icon">
213
+ <svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
214
+ <circle cx="12" cy="12" r="10" />
215
+ <line x1="12" y1="8" x2="12" y2="12" />
216
+ <line x1="12" y1="16" x2="12.01" y2="16" />
217
+ </svg>
218
+ </div>
219
+ <div className="ermis-channel-list__error-text">{text || 'Failed to load channels'}</div>
220
+ {onRetry && (
221
+ <button className="ermis-channel-list__error-retry" onClick={onRetry}>
222
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: '6px' }}>
223
+ <path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8" />
224
+ <path d="M21 3v5h-5" />
225
+ </svg>
226
+ Retry
227
+ </button>
228
+ )}
229
+ </div>
230
+ ));
231
+ DefaultError.displayName = 'DefaultError';
232
+
271
233
  /* ----------------------------------------------------------
272
234
  Virtual Row Component to map channel and defer parsing
273
235
  ---------------------------------------------------------- */
@@ -287,13 +249,23 @@ type ChannelRowProps = {
287
249
  onAddTopic?: (channel: Channel) => void;
288
250
  onEditTopic?: (channel: Channel) => void;
289
251
  onToggleCloseTopic?: (channel: Channel, isClosed: boolean) => void;
252
+ onDeleteTopic?: (channel: Channel) => void;
253
+ onTruncateChannel?: (channel: Channel) => void;
290
254
  hiddenActions?: string[];
291
255
  actionLabels?: import('../types').ChannelActionLabels;
292
256
  actionIcons?: import('../types').ChannelActionIcons;
293
257
  isOnline?: boolean;
258
+ deletedMessageLabel?: React.ReactNode;
259
+ stickerMessageLabel?: React.ReactNode;
260
+ photoMessageLabel?: React.ReactNode;
261
+ videoMessageLabel?: React.ReactNode;
262
+ voiceRecordingMessageLabel?: React.ReactNode;
263
+ fileMessageLabel?: React.ReactNode;
264
+ systemMessageTranslations?: SystemMessageTranslations;
265
+ signalMessageTranslations?: SignalMessageTranslations;
294
266
  };
295
267
 
296
- const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
268
+ export const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
297
269
  channel,
298
270
  isActive,
299
271
  handleSelect,
@@ -309,10 +281,20 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
309
281
  onAddTopic,
310
282
  onEditTopic,
311
283
  onToggleCloseTopic,
284
+ onDeleteTopic,
285
+ onTruncateChannel,
312
286
  hiddenActions,
313
287
  actionLabels,
314
288
  actionIcons,
315
289
  isOnline,
290
+ deletedMessageLabel,
291
+ stickerMessageLabel,
292
+ photoMessageLabel,
293
+ videoMessageLabel,
294
+ voiceRecordingMessageLabel,
295
+ fileMessageLabel,
296
+ systemMessageTranslations,
297
+ signalMessageTranslations,
316
298
  }) => {
317
299
  // Use the new custom hook to handle all row-level realtime updates
318
300
  const { isBannedInChannel, isBlockedInChannel, updateCount } = useChannelRowUpdates(channel, currentUserId);
@@ -330,15 +312,37 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
330
312
 
331
313
  // Derive last message preview computation
332
314
  const { text: rawLastMessageText, user: rawLastMessageUser, timestamp: rawLastMessageTimestamp } = useMemo(
333
- () => getLastMessagePreview(channel, currentUserId),
315
+ () =>
316
+ getLastMessagePreview(channel, currentUserId, {
317
+ deletedMessageLabel,
318
+ stickerMessageLabel,
319
+ photoMessageLabel,
320
+ videoMessageLabel,
321
+ voiceRecordingMessageLabel,
322
+ fileMessageLabel,
323
+ systemMessageTranslations,
324
+ signalMessageTranslations,
325
+ }),
334
326
  // Recompute if latestMessage changes or we get a force update
335
327
  // eslint-disable-next-line react-hooks/exhaustive-deps
336
- [channel, channel.state?.latestMessages, updateCount]
328
+ [
329
+ channel,
330
+ channel.state?.latestMessages,
331
+ updateCount,
332
+ deletedMessageLabel,
333
+ stickerMessageLabel,
334
+ photoMessageLabel,
335
+ videoMessageLabel,
336
+ voiceRecordingMessageLabel,
337
+ fileMessageLabel,
338
+ systemMessageTranslations,
339
+ signalMessageTranslations,
340
+ ]
337
341
  );
338
342
 
339
343
  // Hide last message preview when banned, blocked, pending or skipped
340
344
  const lastMessageText = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageText;
341
- const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? '' : rawLastMessageUser;
345
+ const lastMessageUser = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped || isDirectChannel(channel)) ? '' : rawLastMessageUser;
342
346
  const lastMessageTimestamp = (isBannedInChannel || isBlockedInChannel || isPending || isSkipped) ? null : rawLastMessageTimestamp;
343
347
 
344
348
  if (renderChannel) {
@@ -371,6 +375,8 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
371
375
  onAddTopic={onAddTopic}
372
376
  onEditTopic={onEditTopic}
373
377
  onToggleCloseTopic={onToggleCloseTopic}
378
+ onDeleteTopic={onDeleteTopic}
379
+ onTruncateChannel={onTruncateChannel}
374
380
  hiddenActions={hiddenActions}
375
381
  actionLabels={actionLabels}
376
382
  actionIcons={actionIcons}
@@ -380,194 +386,17 @@ const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
380
386
  });
381
387
  ChannelRow.displayName = 'ChannelRow';
382
388
 
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
389
 
562
390
  export const ChannelList: React.FC<ChannelListProps> = React.memo(({
563
- filters = { type: ['messaging', 'team', 'meeting'], include_pinned_messages: true } as unknown as ChannelFilters,
391
+ filters = { type: ['messaging', 'team', 'meeting'], include_hidden_messages: true } as unknown as ChannelFilters,
564
392
  sort = [],
565
- options = { message_limit: 25 } as unknown as ChannelListProps['options'],
393
+ options = { message_limit: 1 } as unknown as ChannelListProps['options'],
566
394
  renderChannel,
567
395
  onChannelSelect,
568
396
  className,
569
397
  LoadingIndicator = DefaultLoading,
570
398
  EmptyStateIndicator = DefaultEmpty,
399
+ ErrorIndicator,
571
400
  AvatarComponent = Avatar,
572
401
  ChannelItemComponent = ChannelItem,
573
402
  pendingInvitesLabel,
@@ -575,11 +404,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
575
404
  pendingBadgeLabel,
576
405
  loadingLabel,
577
406
  emptyStateLabel = 'No channels found',
407
+ errorLabel = 'Failed to load channels',
578
408
  blockedBadgeLabel = 'Blocked',
579
- ChannelTopicGroupComponent,
580
- GeneralTopicAvatarComponent,
581
- TopicAvatarComponent,
582
- generalTopicLabel = 'general',
583
409
  onAddTopic,
584
410
  TopicEmojiPickerComponent,
585
411
  closedTopicIcon,
@@ -587,18 +413,49 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
587
413
  ChannelActionsComponent,
588
414
  onEditTopic,
589
415
  onToggleCloseTopic,
416
+ onDeleteTopic,
417
+ onTruncateChannel,
590
418
  hiddenActions,
591
419
  actionLabels,
592
420
  actionIcons,
593
421
  showOnlineStatus = true,
422
+ showPendingInvites = true,
423
+ onTopicDrillDown,
424
+ maxVisibleTopics,
425
+ moreTopicsLabel,
426
+ generalTopicLabel = 'general',
427
+ TopicPillComponent,
428
+ FlatTopicGroupItemComponent,
429
+ scrollToTopOnOwnMessage = true,
430
+ deletedMessageLabel,
431
+ stickerMessageLabel,
432
+ photoMessageLabel,
433
+ videoMessageLabel,
434
+ voiceRecordingMessageLabel,
435
+ fileMessageLabel,
436
+ systemMessageTranslations,
437
+ signalMessageTranslations,
594
438
  }) => {
595
439
  const { client, activeChannel, setActiveChannel } = useChatClient();
440
+ const { ChannelListErrorIndicator } = useChatComponents();
441
+
596
442
  const [channels, setChannels] = useState<Channel[]>([]);
597
443
  const [loading, setLoading] = useState(true);
444
+ const [error, setError] = useState<any>(null);
445
+
446
+ const ActualErrorIndicator = ErrorIndicator || ChannelListErrorIndicator || DefaultError;
598
447
  const [isPendingExpanded, setIsPendingExpanded] = useState(true);
599
448
  const [addingTopicForChannel, setAddingTopicForChannel] = useState<Channel | null>(null);
600
449
  const [editingTopicForChannel, setEditingTopicForChannel] = useState<Channel | null>(null);
601
450
 
451
+ // Ref for imperative scroll control on the virtualized list
452
+ const vlistRef = useRef<VListHandle>(null);
453
+
454
+ // Scroll to top when the current user sends a message
455
+ const handleOwnMessageNew = useCallback(() => {
456
+ vlistRef.current?.scrollToIndex(0);
457
+ }, []);
458
+
602
459
  const handleAddTopicClick = useCallback((channel: Channel) => {
603
460
  if (onAddTopic) {
604
461
  onAddTopic(channel);
@@ -648,7 +505,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
648
505
  const ms = ch.state?.membership as Record<string, unknown> | undefined;
649
506
  const isPending = isPendingMember(ms?.channel_role as string);
650
507
  const isSkipped = isSkippedMember(ms?.channel_role as string);
651
-
508
+
652
509
  if (isSkipped) {
653
510
  return; // Filter out completely
654
511
  }
@@ -670,10 +527,12 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
670
527
  const loadChannels = useCallback(async () => {
671
528
  try {
672
529
  setLoading(true);
530
+ setError(null);
673
531
  const result = await client.queryChannels(filters, sort, options as { message_limit?: number });
674
532
  setChannels(result);
675
533
  } catch (err) {
676
534
  console.error('Failed to load channels:', err);
535
+ setError(err);
677
536
  } finally {
678
537
  setLoading(false);
679
538
  }
@@ -684,7 +543,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
684
543
  }, [loadChannels]);
685
544
 
686
545
  // Real-time: List manipulation (move to top, add, delete)
687
- useChannelListUpdates(channels, setChannels);
546
+ useChannelListUpdates(channels, setChannels, scrollToTopOnOwnMessage ? handleOwnMessageNew : undefined);
688
547
 
689
548
  // Online status: compute set of online friend user IDs (skip if disabled)
690
549
  const onlineUsers = useOnlineUsers(showOnlineStatus ? channels : []);
@@ -732,13 +591,19 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
732
591
  );
733
592
 
734
593
  if (loading) return <LoadingIndicator text={loadingLabel} />;
735
- if (channels.length === 0) return <EmptyStateIndicator text={emptyStateLabel} />;
594
+ if (error) return <ActualErrorIndicator text={errorLabel} onRetry={loadChannels} />;
595
+
596
+ const isEmpty = showPendingInvites
597
+ ? (pendingChannels.length === 0 && regularChannels.length === 0)
598
+ : (regularChannels.length === 0);
599
+
600
+ if (isEmpty) return <EmptyStateIndicator text={emptyStateLabel} />;
736
601
 
737
602
  return (
738
603
  <div className={`ermis-channel-list${className ? ` ${className}` : ''}`}>
739
604
  {/* VList requires its container to have a height to work. */}
740
- <VList style={{ height: '100%' }}>
741
- {pendingChannels.length > 0 && (
605
+ <VList ref={vlistRef} style={{ height: '100%' }}>
606
+ {showPendingInvites && pendingChannels.length > 0 && (
742
607
  <div
743
608
  className="ermis-channel-list__accordion-header"
744
609
  onClick={() => setIsPendingExpanded(prev => !prev)}
@@ -756,7 +621,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
756
621
  </svg>
757
622
  </div>
758
623
  )}
759
- {isPendingExpanded && pendingChannels.map((channel: Channel) => {
624
+ {showPendingInvites && isPendingExpanded && pendingChannels.map((channel: Channel) => {
760
625
  const isActive = activeChannel?.cid === channel.cid;
761
626
  return (
762
627
  <ChannelRow
@@ -773,48 +638,60 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
773
638
  closedTopicIcon={closedTopicIcon}
774
639
  PinnedIconComponent={PinnedIconComponent}
775
640
  ChannelActionsComponent={ChannelActionsComponent}
641
+ onTruncateChannel={onTruncateChannel}
776
642
  hiddenActions={hiddenActions}
777
643
  actionLabels={actionLabels}
778
644
  actionIcons={actionIcons}
779
645
  isOnline={getIsOnline(channel)}
646
+ deletedMessageLabel={deletedMessageLabel}
647
+ stickerMessageLabel={stickerMessageLabel}
648
+ photoMessageLabel={photoMessageLabel}
649
+ videoMessageLabel={videoMessageLabel}
650
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
651
+ fileMessageLabel={fileMessageLabel}
652
+ systemMessageTranslations={systemMessageTranslations}
653
+ signalMessageTranslations={signalMessageTranslations}
780
654
  />
781
655
  );
782
656
  })}
783
- {pendingChannels.length > 0 && regularChannels.length > 0 && (
657
+ {/* {pendingChannels.length > 0 && regularChannels.length > 0 && (
784
658
  <div className="ermis-channel-list__accordion-header ermis-channel-list__accordion-header--static">
785
659
  <span>{channelsLabel}</span>
786
660
  </div>
787
- )}
661
+ )} */}
788
662
  {regularChannels.map((channel: Channel) => {
789
663
  const isActive = activeChannel?.cid === channel.cid;
790
664
  const isTeamWithTopics = hasTopicsEnabled(channel);
791
665
 
792
666
  if (isTeamWithTopics) {
793
- const GroupComponent = ChannelTopicGroupComponent || ChannelTopicGroup;
667
+ // Drill-down mode: always render flat item with topic pills + last msg
668
+ const FlatComponent = FlatTopicGroupItemComponent || FlatTopicGroupItem;
794
669
  return (
795
- <GroupComponent
670
+ <FlatComponent
796
671
  key={channel.cid}
797
672
  channel={channel}
798
- activeChannel={activeChannel}
799
- handleSelect={handleSelect}
800
- renderChannel={renderChannel}
801
- ChannelItemComponent={ChannelItemComponent}
673
+ isActive={isActive}
674
+ onDrillDown={onTopicDrillDown}
802
675
  AvatarComponent={AvatarComponent}
803
- GeneralTopicAvatarComponent={GeneralTopicAvatarComponent}
804
- TopicAvatarComponent={TopicAvatarComponent}
805
- currentUserId={client.userID}
806
- pendingBadgeLabel={pendingBadgeLabel}
807
- blockedBadgeLabel={blockedBadgeLabel}
676
+ maxVisibleTopics={maxVisibleTopics}
677
+ moreTopicsLabel={moreTopicsLabel}
808
678
  generalTopicLabel={generalTopicLabel}
809
- onAddTopic={handleAddTopicClick}
810
- closedTopicIcon={closedTopicIcon}
679
+ TopicPillComponent={TopicPillComponent}
811
680
  PinnedIconComponent={PinnedIconComponent}
812
681
  ChannelActionsComponent={ChannelActionsComponent}
813
- onEditTopic={handleEditTopicClick}
814
- onToggleCloseTopic={handleToggleCloseTopicClick}
682
+ onAddTopic={handleAddTopicClick}
683
+ onTruncateChannel={onTruncateChannel}
815
684
  hiddenActions={hiddenActions}
816
685
  actionLabels={actionLabels}
817
686
  actionIcons={actionIcons}
687
+ deletedMessageLabel={deletedMessageLabel}
688
+ stickerMessageLabel={stickerMessageLabel}
689
+ photoMessageLabel={photoMessageLabel}
690
+ videoMessageLabel={videoMessageLabel}
691
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
692
+ fileMessageLabel={fileMessageLabel}
693
+ systemMessageTranslations={systemMessageTranslations}
694
+ signalMessageTranslations={signalMessageTranslations}
818
695
  />
819
696
  );
820
697
  }
@@ -837,10 +714,20 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
837
714
  onAddTopic={handleAddTopicClick}
838
715
  onEditTopic={handleEditTopicClick}
839
716
  onToggleCloseTopic={handleToggleCloseTopicClick}
717
+ onDeleteTopic={onDeleteTopic}
718
+ onTruncateChannel={onTruncateChannel}
840
719
  hiddenActions={hiddenActions}
841
720
  actionLabels={actionLabels}
842
721
  actionIcons={actionIcons}
843
722
  isOnline={getIsOnline(channel)}
723
+ deletedMessageLabel={deletedMessageLabel}
724
+ stickerMessageLabel={stickerMessageLabel}
725
+ photoMessageLabel={photoMessageLabel}
726
+ videoMessageLabel={videoMessageLabel}
727
+ voiceRecordingMessageLabel={voiceRecordingMessageLabel}
728
+ fileMessageLabel={fileMessageLabel}
729
+ systemMessageTranslations={systemMessageTranslations}
730
+ signalMessageTranslations={signalMessageTranslations}
844
731
  />
845
732
  );
846
733
  })}
@@ -865,4 +752,4 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
865
752
  );
866
753
  });
867
754
 
868
- ChannelList.displayName = 'ChannelList'; 'ChannelList';
755
+ ChannelList.displayName = 'ChannelList';