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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +5087 -11279
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +632 -152
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +273 -9
  7. package/dist/index.d.ts +273 -9
  8. package/dist/index.mjs +5085 -11295
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +2 -2
  11. package/src/components/Channel.tsx +0 -3
  12. package/src/components/ChannelActions.tsx +6 -1
  13. package/src/components/ChannelHeader.tsx +8 -32
  14. package/src/components/ChannelInfo/AddMemberModal.tsx +7 -1
  15. package/src/components/ChannelInfo/ChannelInfo.tsx +82 -2
  16. package/src/components/ChannelInfo/EditChannelModal.tsx +2 -2
  17. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -78
  18. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +170 -129
  19. package/src/components/ChannelList.tsx +72 -13
  20. package/src/components/CreateChannelModal.tsx +131 -12
  21. package/src/components/FilesPreview.tsx +8 -12
  22. package/src/components/FlatTopicGroupItem.tsx +27 -16
  23. package/src/components/ForwardMessageModal.tsx +11 -3
  24. package/src/components/MediaLightbox.tsx +444 -304
  25. package/src/components/MessageActionsBox.tsx +2 -0
  26. package/src/components/MessageInput.tsx +41 -12
  27. package/src/components/MessageItem.tsx +70 -25
  28. package/src/components/MessageQuickReactions.tsx +131 -128
  29. package/src/components/MessageReactions.tsx +47 -2
  30. package/src/components/MessageRenderers.tsx +1030 -433
  31. package/src/components/PinnedMessages.tsx +40 -12
  32. package/src/components/QuotedMessagePreview.tsx +99 -8
  33. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  34. package/src/components/RecoveryPin/index.ts +19 -0
  35. package/src/components/TopicList.tsx +20 -5
  36. package/src/components/TypingIndicator.tsx +3 -3
  37. package/src/components/UserPicker.tsx +26 -25
  38. package/src/components/VirtualMessageList.tsx +345 -125
  39. package/src/context/ChatProvider.tsx +27 -1
  40. package/src/hooks/useChannelListUpdates.ts +22 -1
  41. package/src/hooks/useChannelMessages.ts +338 -51
  42. package/src/hooks/useChannelRowUpdates.ts +18 -6
  43. package/src/hooks/useChatUser.ts +9 -1
  44. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  45. package/src/hooks/useE2eeFileUpload.ts +38 -0
  46. package/src/hooks/useFileUpload.ts +25 -5
  47. package/src/hooks/useForwardMessage.ts +210 -13
  48. package/src/hooks/useLoadMessages.ts +16 -4
  49. package/src/hooks/useMentions.ts +60 -6
  50. package/src/hooks/useMessageActions.ts +14 -8
  51. package/src/hooks/useMessageSend.ts +64 -12
  52. package/src/hooks/usePendingE2eeSends.ts +29 -0
  53. package/src/hooks/useRecoveryPin.ts +287 -0
  54. package/src/hooks/useScrollToMessage.ts +29 -4
  55. package/src/hooks/useTopicGroupUpdates.ts +49 -11
  56. package/src/index.ts +23 -0
  57. package/src/messageTypeUtils.ts +14 -0
  58. package/src/styles/_channel-info.css +9 -0
  59. package/src/styles/_channel-list.css +37 -14
  60. package/src/styles/_media-lightbox.css +36 -3
  61. package/src/styles/_message-bubble.css +381 -41
  62. package/src/styles/_message-input.css +8 -0
  63. package/src/styles/_message-list.css +67 -10
  64. package/src/styles/_message-quick-reactions.css +101 -59
  65. package/src/styles/_message-reactions.css +18 -32
  66. package/src/styles/_recovery-pin.css +97 -0
  67. package/src/styles/_tokens.css +5 -5
  68. package/src/styles/_typing-indicator.css +23 -13
  69. package/src/styles/index.css +1 -0
  70. package/src/types.ts +115 -1
  71. package/src/utils/avatarColors.ts +1 -1
  72. package/src/utils.ts +38 -18
@@ -15,7 +15,7 @@ import {
15
15
  canRemoveTargetMember,
16
16
  canBanTargetMember,
17
17
  canPromoteTargetMember,
18
- canDemoteTargetMember
18
+ canDemoteTargetMember,
19
19
  } from '../../channelRoleUtils';
20
20
 
21
21
  export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
@@ -52,7 +52,7 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
52
52
  const availableTabs: MediaTab[] = useMemo(() => {
53
53
  let tabs = isMessaging ? MESSAGING_TABS : ALL_TABS;
54
54
  if (isTopic) {
55
- tabs = tabs.filter(t => t !== 'members');
55
+ tabs = tabs.filter((t) => t !== 'members');
56
56
  }
57
57
  return tabs;
58
58
  }, [isMessaging, isTopic]);
@@ -65,31 +65,34 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
65
65
  const lastFetchedCidRef = useRef<string | null>(null);
66
66
  const transitionRafRef = useRef<any>(null);
67
67
 
68
- const handleTabChange = useCallback((tab: MediaTab) => {
69
- if (tab === activeTab) return;
68
+ const handleTabChange = useCallback(
69
+ (tab: MediaTab) => {
70
+ if (tab === activeTab) return;
70
71
 
71
- // 1. Instant UI update for the tab button
72
- setActiveTab(tab);
72
+ // 1. Instant UI update for the tab button
73
+ setActiveTab(tab);
73
74
 
74
- if (transitionRafRef.current) clearTimeout(transitionRafRef.current);
75
+ if (transitionRafRef.current) clearTimeout(transitionRafRef.current);
76
+
77
+ // Check if data is already available for this channel
78
+ const hasData = tab === 'members' || attachmentsFetchedForCid === channel?.cid;
75
79
 
76
- // Check if data is already available for this channel
77
- const hasData = tab === 'members' || attachmentsFetchedForCid === channel?.cid;
78
-
79
- if (hasData) {
80
- // If data exists, switch content immediately without loading state
81
- setContentTab(tab);
82
- setIsPending(false);
83
- } else {
84
- // If no data, use isPending to show Skeleton while waiting for API
85
- setIsPending(true);
86
- transitionRafRef.current = setTimeout(() => {
80
+ if (hasData) {
81
+ // If data exists, switch content immediately without loading state
87
82
  setContentTab(tab);
88
83
  setIsPending(false);
89
- setAttachmentsFetchedForCid((prev) => prev || channel?.cid || null);
90
- }, 350);
91
- }
92
- }, [activeTab, channel?.cid, attachmentsFetchedForCid]);
84
+ } else {
85
+ // If no data, use isPending to show Skeleton while waiting for API
86
+ setIsPending(true);
87
+ transitionRafRef.current = setTimeout(() => {
88
+ setContentTab(tab);
89
+ setIsPending(false);
90
+ setAttachmentsFetchedForCid((prev) => prev || channel?.cid || null);
91
+ }, 350);
92
+ }
93
+ },
94
+ [activeTab, channel?.cid, attachmentsFetchedForCid],
95
+ );
93
96
 
94
97
  // Reset tab when user switches channels
95
98
  useEffect(() => {
@@ -128,7 +131,7 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
128
131
 
129
132
  const forceRefreshAttachments = useCallback(() => {
130
133
  lastFetchedCidRef.current = null;
131
- setRefreshAttachmentsCount(c => c + 1);
134
+ setRefreshAttachmentsCount((c) => c + 1);
132
135
  }, []);
133
136
 
134
137
  const sortedMembers = useMemo(() => {
@@ -140,19 +143,16 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
140
143
  }, [members]);
141
144
 
142
145
  // Categorize attachments by type
143
- const mediaItems = useMemo(() =>
144
- allAttachments.filter(a => a.attachment_type === 'image' || a.attachment_type === 'video'),
145
- [allAttachments]
146
+ const mediaItems = useMemo(
147
+ () => allAttachments.filter((a) => a.attachment_type === 'image' || a.attachment_type === 'video'),
148
+ [allAttachments],
146
149
  );
147
150
 
148
- const linkItems = useMemo(() =>
149
- allAttachments.filter(a => a.attachment_type === 'linkPreview'),
150
- [allAttachments]
151
- );
151
+ const linkItems = useMemo(() => allAttachments.filter((a) => a.attachment_type === 'linkPreview'), [allAttachments]);
152
152
 
153
- const fileItems = useMemo(() =>
154
- allAttachments.filter(a => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
155
- [allAttachments]
153
+ const fileItems = useMemo(
154
+ () => allAttachments.filter((a) => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
155
+ [allAttachments],
156
156
  );
157
157
 
158
158
  useEffect(() => {
@@ -183,7 +183,7 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
183
183
  lastFetchedCidRef.current = channel?.cid || null;
184
184
  }
185
185
  } catch (err) {
186
- console.error("Failed to query media for channel info", err);
186
+ console.error('Failed to query media for channel info', err);
187
187
  if (active) setAllAttachments([]);
188
188
  } finally {
189
189
  if (active) setLoading(false);
@@ -191,7 +191,9 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
191
191
  };
192
192
 
193
193
  fetchMedia();
194
- return () => { active = false; };
194
+ return () => {
195
+ active = false;
196
+ };
195
197
  }, [channel, isBanned, isBlocked, isPreviewMode, attachmentsFetchedForCid, isVisible, refreshAttachmentsCount]);
196
198
 
197
199
  // Listen to realtime events to automatically refresh attachments
@@ -217,30 +219,40 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
217
219
 
218
220
  const { downloadFile } = useDownloadHandler();
219
221
 
220
- const handleDownloadFile = useCallback(async (url: string, filename?: string) => {
221
- await downloadFile(url, filename);
222
- }, [downloadFile]);
222
+ const handleDownloadFile = useCallback(
223
+ async (url: string, filename?: string) => {
224
+ await downloadFile(url, filename);
225
+ },
226
+ [downloadFile],
227
+ );
223
228
 
224
229
  // Lightbox state for media tab
225
230
  const [lightboxOpen, setLightboxOpen] = useState(false);
226
231
  const [lightboxIndex, setLightboxIndex] = useState(0);
227
232
 
228
233
  const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
229
- return mediaItems.map(item => ({
230
- type: (item.attachment_type === 'video' ? 'video' : 'image') as 'image' | 'video',
231
- src: item.url,
232
- alt: item.file_name,
233
- posterSrc: item.thumb_url || undefined,
234
- }));
234
+ return mediaItems
235
+ .filter((item) => !item.e2ee_manifest && !item.e2ee_manifest_missing)
236
+ .map((item) => ({
237
+ type: (item.attachment_type === 'video' ? 'video' : 'image') as 'image' | 'video',
238
+ src: item.url,
239
+ alt: item.file_name,
240
+ posterSrc: item.thumb_url || undefined,
241
+ }));
235
242
  }, [mediaItems]);
236
243
 
237
- const handleMediaClick = useCallback((url: string) => {
238
- const idx = mediaItems.findIndex(item => item.url === url);
239
- if (idx >= 0) {
240
- setLightboxIndex(idx);
241
- setLightboxOpen(true);
242
- }
243
- }, [mediaItems]);
244
+ const handleMediaClick = useCallback(
245
+ (url: string) => {
246
+ const idx = mediaItems
247
+ .filter((item) => !item.e2ee_manifest && !item.e2ee_manifest_missing)
248
+ .findIndex((item) => item.url === url);
249
+ if (idx >= 0) {
250
+ setLightboxIndex(idx);
251
+ setLightboxOpen(true);
252
+ }
253
+ },
254
+ [mediaItems],
255
+ );
244
256
 
245
257
  const closeLightbox = useCallback(() => {
246
258
  setLightboxOpen(false);
@@ -263,103 +275,132 @@ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
263
275
  if (onAddMemberClick) {
264
276
  items.push({ type: 'add-member' });
265
277
  }
266
- sortedMembers.forEach(member => {
278
+ sortedMembers.forEach((member) => {
267
279
  items.push({ type: 'member', data: member });
268
280
  });
269
281
  return items;
270
282
  }
271
283
  case 'media':
272
- return mediaRows.map(row => ({ type: 'media-row', data: row }));
284
+ return mediaRows.map((row) => ({ type: 'media-row', data: row }));
273
285
  case 'links':
274
- return linkItems.map(item => ({ type: 'link', data: item }));
286
+ return linkItems.map((item) => ({ type: 'link', data: item }));
275
287
  case 'files':
276
- return fileItems.map(item => ({ type: 'file', data: item }));
288
+ return fileItems.map((item) => ({ type: 'file', data: item }));
277
289
  default:
278
290
  return [];
279
291
  }
280
292
  }, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick]);
281
293
 
282
294
  // Render function for VList items
283
- const renderVlistItem = useCallback((item: any, index: number) => {
284
- switch (item.type) {
285
- case 'add-member':
286
- if (AddMemberButtonComponent) {
295
+ const renderVlistItem = useCallback(
296
+ (item: any, index: number) => {
297
+ switch (item.type) {
298
+ case 'add-member':
299
+ if (AddMemberButtonComponent) {
300
+ return (
301
+ <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
302
+ <AddMemberButtonComponent onClick={onAddMemberClick!} label={addMemberButtonLabel} />
303
+ </div>
304
+ );
305
+ }
287
306
  return (
288
307
  <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
289
- <AddMemberButtonComponent onClick={onAddMemberClick!} label={addMemberButtonLabel} />
308
+ <button className="ermis-channel-info__add-member-btn" onClick={onAddMemberClick}>
309
+ <svg
310
+ width="18"
311
+ height="18"
312
+ viewBox="0 0 24 24"
313
+ fill="none"
314
+ stroke="currentColor"
315
+ strokeWidth="2"
316
+ strokeLinecap="round"
317
+ strokeLinejoin="round"
318
+ >
319
+ <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
320
+ <circle cx="8.5" cy="7" r="4"></circle>
321
+ <line x1="20" y1="8" x2="20" y2="14"></line>
322
+ <line x1="23" y1="11" x2="17" y2="11"></line>
323
+ </svg>
324
+ {addMemberButtonLabel}
325
+ </button>
290
326
  </div>
291
327
  );
328
+ case 'member': {
329
+ const member = item.data;
330
+ const role = member.channel_role || CHANNEL_ROLES.MEMBER;
331
+ const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
332
+ const canRemove = Boolean(isTargetRemovable && member.user_id !== currentUserId);
333
+ const canBan = Boolean(
334
+ canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && !member.banned,
335
+ );
336
+ const canUnban = Boolean(
337
+ canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && member.banned,
338
+ );
339
+ const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
340
+ const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
341
+
342
+ return (
343
+ <MemberItem
344
+ key={member?.user_id || index}
345
+ member={member}
346
+ AvatarComponent={AvatarComponent}
347
+ onRemove={onRemoveMember}
348
+ canRemove={canRemove}
349
+ onBan={onBanMember}
350
+ canBan={canBan}
351
+ onUnban={onUnbanMember}
352
+ canUnban={canUnban}
353
+ onPromote={onPromoteMember}
354
+ canPromote={canPromote}
355
+ onDemote={onDemoteMember}
356
+ canDemote={canDemote}
357
+ />
358
+ );
292
359
  }
293
- return (
294
- <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
295
- <button className="ermis-channel-info__add-member-btn" onClick={onAddMemberClick}>
296
- <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
297
- <path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path>
298
- <circle cx="8.5" cy="7" r="4"></circle>
299
- <line x1="20" y1="8" x2="20" y2="14"></line>
300
- <line x1="23" y1="11" x2="17" y2="11"></line>
301
- </svg>
302
- {addMemberButtonLabel}
303
- </button>
304
- </div>
305
- );
306
- case 'member': {
307
- const member = item.data;
308
- const role = member.channel_role || CHANNEL_ROLES.MEMBER;
309
- const isTargetRemovable = canRemoveTargetMember(currentUserRole, role);
310
- const canRemove = Boolean(isTargetRemovable && member.user_id !== currentUserId);
311
- const canBan = Boolean(canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && !member.banned);
312
- const canUnban = Boolean(canBanTargetMember(currentUserRole, role) && member.user_id !== currentUserId && member.banned);
313
- const canPromote = canPromoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
314
- const canDemote = canDemoteTargetMember(currentUserRole, role) && member.user_id !== currentUserId;
315
-
316
- return (
317
- <MemberItem
318
- key={member?.user_id || index}
319
- member={member}
320
- AvatarComponent={AvatarComponent}
321
- onRemove={onRemoveMember}
322
- canRemove={canRemove}
323
- onBan={onBanMember}
324
- canBan={canBan}
325
- onUnban={onUnbanMember}
326
- canUnban={canUnban}
327
- onPromote={onPromoteMember}
328
- canPromote={canPromote}
329
- onDemote={onDemoteMember}
330
- canDemote={canDemote}
331
- />
332
- );
360
+ case 'media-row':
361
+ return (
362
+ <MediaRow
363
+ key={item.data[0]?.id || index}
364
+ row={item.data}
365
+ onClick={handleMediaClick}
366
+ MediaItemComponent={MediaItem}
367
+ />
368
+ );
369
+ case 'link':
370
+ return <LinkItem key={item.data.id || index} item={item.data} />;
371
+ case 'file':
372
+ const fileItem = item.data as AttachmentItem;
373
+ return (
374
+ <FileItem
375
+ key={fileItem.id || index}
376
+ item={fileItem}
377
+ onClick={(url: string) => handleDownloadFile(url, fileItem.file_name)}
378
+ />
379
+ );
380
+ default:
381
+ return null;
333
382
  }
334
- case 'media-row':
335
- return (
336
- <MediaRow
337
- key={item.data[0]?.id || index}
338
- row={item.data}
339
- onClick={handleMediaClick}
340
- MediaItemComponent={MediaItem}
341
- />
342
- );
343
- case 'link':
344
- return <LinkItem key={item.data.id || index} item={item.data} />;
345
- case 'file':
346
- const fileItem = item.data as AttachmentItem;
347
- return (
348
- <FileItem
349
- key={fileItem.id || index}
350
- item={fileItem}
351
- onClick={(url: string) => handleDownloadFile(url, fileItem.file_name)}
352
- />
353
- );
354
- default:
355
- return null;
356
- }
357
- }, [
358
- onAddMemberClick, AddMemberButtonComponent, addMemberButtonLabel,
359
- currentUserRole, currentUserId, AvatarComponent, onRemoveMember, onBanMember, onUnbanMember, onPromoteMember, onDemoteMember,
360
- handleMediaClick, MediaItem, handleDownloadFile,
361
- MemberItem, LinkItem, FileItem
362
- ]);
383
+ },
384
+ [
385
+ onAddMemberClick,
386
+ AddMemberButtonComponent,
387
+ addMemberButtonLabel,
388
+ currentUserRole,
389
+ currentUserId,
390
+ AvatarComponent,
391
+ onRemoveMember,
392
+ onBanMember,
393
+ onUnbanMember,
394
+ onPromoteMember,
395
+ onDemoteMember,
396
+ handleMediaClick,
397
+ MediaItem,
398
+ handleDownloadFile,
399
+ MemberItem,
400
+ LinkItem,
401
+ FileItem,
402
+ ],
403
+ );
363
404
 
364
405
  const isTabEmpty = vlistData.length === 0 && !(loading && contentTab !== 'members');
365
406
  const emptyLabel = contentTab === 'members' ? 'members' : contentTab;
@@ -89,8 +89,28 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
89
89
  }, [defaultActions, hiddenActions]);
90
90
  const ActionsComponent = ChannelActionsComponent || DefaultChannelActions;
91
91
 
92
- const name = channel.data?.name || channel.cid;
93
- 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;
94
114
  const showUnread = hasUnread && !isActive;
95
115
  const avatarClassName = isGroupChannel(channel) ? 'ermis-avatar-wrapper--group' : undefined;
96
116
 
@@ -119,10 +139,15 @@ export const ChannelItem: React.FC<ChannelItemProps> = React.memo(({
119
139
  return (
120
140
  <div className={itemClass} onClick={handleClick}>
121
141
  <div className="ermis-channel-list__item-avatar-wrapper">
122
- <AvatarComponent image={image} name={name} size={40} disableLightbox className={avatarClassName} />
142
+ <AvatarComponent image={image} name={name} size={45} disableLightbox className={avatarClassName} />
123
143
  {isOnline !== undefined && (
124
144
  <span className={`ermis-channel-list__online-dot ermis-channel-list__online-dot--${isOnline ? 'online' : 'offline'}`} />
125
145
  )}
146
+ {showUnread && unreadCount > 0 && (
147
+ <span className="ermis-channel-list__avatar-unread-badge">
148
+ {unreadCount > 99 ? '99+' : unreadCount}
149
+ </span>
150
+ )}
126
151
  </div>
127
152
  <div className="ermis-channel-list__item-content">
128
153
  <div className="ermis-channel-list__item-top-row">
@@ -261,6 +286,8 @@ type ChannelRowProps = {
261
286
  videoMessageLabel?: React.ReactNode;
262
287
  voiceRecordingMessageLabel?: React.ReactNode;
263
288
  fileMessageLabel?: React.ReactNode;
289
+ encryptedMessageLabel?: React.ReactNode;
290
+ encryptedMessageUnavailableLabel?: React.ReactNode;
264
291
  systemMessageTranslations?: SystemMessageTranslations;
265
292
  signalMessageTranslations?: SignalMessageTranslations;
266
293
  };
@@ -293,6 +320,8 @@ export const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
293
320
  videoMessageLabel,
294
321
  voiceRecordingMessageLabel,
295
322
  fileMessageLabel,
323
+ encryptedMessageLabel,
324
+ encryptedMessageUnavailableLabel,
296
325
  systemMessageTranslations,
297
326
  signalMessageTranslations,
298
327
  }) => {
@@ -320,6 +349,8 @@ export const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
320
349
  videoMessageLabel,
321
350
  voiceRecordingMessageLabel,
322
351
  fileMessageLabel,
352
+ encryptedMessageLabel,
353
+ encryptedMessageUnavailableLabel,
323
354
  systemMessageTranslations,
324
355
  signalMessageTranslations,
325
356
  }),
@@ -335,6 +366,8 @@ export const ChannelRow: React.FC<ChannelRowProps> = React.memo(({
335
366
  videoMessageLabel,
336
367
  voiceRecordingMessageLabel,
337
368
  fileMessageLabel,
369
+ encryptedMessageLabel,
370
+ encryptedMessageUnavailableLabel,
338
371
  systemMessageTranslations,
339
372
  signalMessageTranslations,
340
373
  ]
@@ -433,8 +466,11 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
433
466
  videoMessageLabel,
434
467
  voiceRecordingMessageLabel,
435
468
  fileMessageLabel,
469
+ encryptedMessageLabel,
470
+ encryptedMessageUnavailableLabel,
436
471
  systemMessageTranslations,
437
472
  signalMessageTranslations,
473
+ showTopicPills = false,
438
474
  }) => {
439
475
  const { client, activeChannel, setActiveChannel } = useChatClient();
440
476
  const { ChannelListErrorIndicator } = useChatComponents();
@@ -573,18 +609,32 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
573
609
  onChannelSelect?.(channel);
574
610
 
575
611
  // Mark as read when user selects a channel (skip if banned, blocked, or pending)
576
- const ms = channel.state?.membership as Record<string, unknown> | undefined;
577
- 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;
578
615
  const isBannedInChannel = Boolean(ms?.banned);
579
- const isBlockedInChannel = isDirectChannel(channel) && Boolean(ms?.blocked);
616
+ const isBlockedInChannel = isDirectChannel(activeCh) && Boolean(ms?.blocked);
580
617
  const isPending = isPendingMember(ms?.channel_role as string);
581
618
  const isSkipped = isSkippedMember(ms?.channel_role as string);
582
619
 
583
- if (!isBannedInChannel && !isBlockedInChannel && !isPending && !isSkipped && (chState?.unreadCount as number) > 0) {
584
- channel.markRead().catch(() => { });
585
- // Optimistically reset unread to update UI immediately
586
- if (chState) chState.unreadCount = 0;
587
- 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
+ }
588
638
  }
589
639
  },
590
640
  [setActiveChannel, onChannelSelect, setChannels],
@@ -649,6 +699,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
649
699
  videoMessageLabel={videoMessageLabel}
650
700
  voiceRecordingMessageLabel={voiceRecordingMessageLabel}
651
701
  fileMessageLabel={fileMessageLabel}
702
+ encryptedMessageLabel={encryptedMessageLabel}
703
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
652
704
  systemMessageTranslations={systemMessageTranslations}
653
705
  signalMessageTranslations={signalMessageTranslations}
654
706
  />
@@ -660,7 +712,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
660
712
  </div>
661
713
  )} */}
662
714
  {regularChannels.map((channel: Channel) => {
663
- const isActive = activeChannel?.cid === channel.cid;
715
+ const isActive = activeChannel?.cid === channel.cid ||
716
+ (activeChannel?.data?.parent_cid === channel.cid);
664
717
  const isTeamWithTopics = hasTopicsEnabled(channel);
665
718
 
666
719
  if (isTeamWithTopics) {
@@ -671,7 +724,10 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
671
724
  key={channel.cid}
672
725
  channel={channel}
673
726
  isActive={isActive}
674
- onDrillDown={onTopicDrillDown}
727
+ onDrillDown={(c) => {
728
+ handleSelect(c);
729
+ if (onTopicDrillDown) onTopicDrillDown(c);
730
+ }}
675
731
  AvatarComponent={AvatarComponent}
676
732
  maxVisibleTopics={maxVisibleTopics}
677
733
  moreTopicsLabel={moreTopicsLabel}
@@ -692,6 +748,7 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
692
748
  fileMessageLabel={fileMessageLabel}
693
749
  systemMessageTranslations={systemMessageTranslations}
694
750
  signalMessageTranslations={signalMessageTranslations}
751
+ showTopicPills={showTopicPills}
695
752
  />
696
753
  );
697
754
  }
@@ -726,6 +783,8 @@ export const ChannelList: React.FC<ChannelListProps> = React.memo(({
726
783
  videoMessageLabel={videoMessageLabel}
727
784
  voiceRecordingMessageLabel={voiceRecordingMessageLabel}
728
785
  fileMessageLabel={fileMessageLabel}
786
+ encryptedMessageLabel={encryptedMessageLabel}
787
+ encryptedMessageUnavailableLabel={encryptedMessageUnavailableLabel}
729
788
  systemMessageTranslations={systemMessageTranslations}
730
789
  signalMessageTranslations={signalMessageTranslations}
731
790
  />