@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,14 +1,20 @@
1
- import React, { useState, useEffect, useCallback, useMemo } from 'react';
1
+ import React, { useState, useCallback, useRef, useEffect } from 'react';
2
+ import { Virtualizer as _Virtualizer } from 'virtua';
3
+ const Virtualizer = _Virtualizer as any;
2
4
  import { useChatClient } from '../../hooks/useChatClient';
3
5
  import { useBannedState } from '../../hooks/useBannedState';
4
6
  import { useBlockedState } from '../../hooks/useBlockedState';
7
+ import { usePreviewState } from '../../hooks/usePreviewState';
5
8
  import { Avatar } from '../Avatar';
6
- import { DefaultChannelInfoTabs } from './ChannelInfoTabs';
9
+ import { DefaultChannelInfoTabHeader } from './ChannelInfoTabs';
10
+ import { useChannelInfoTabs } from './useChannelInfoTabs';
7
11
  import { AddMemberModal } from './AddMemberModal';
8
12
  import { EditChannelModal } from './EditChannelModal';
9
13
  import { TopicModal } from '../TopicModal';
10
14
  import { MessageSearchPanel } from './MessageSearchPanel';
11
15
  import { ChannelSettingsPanel } from './ChannelSettingsPanel';
16
+ import { MediaLightbox } from '../MediaLightbox';
17
+ import { PENDING_STYLE, READY_STYLE } from './utils';
12
18
  import type {
13
19
  ChannelInfoProps,
14
20
  ChannelInfoHeaderProps,
@@ -19,6 +25,22 @@ import { useChannelMembers, useChannelProfile } from '../../hooks/useChannelData
19
25
  import { isGroupChannel, isTopicChannel } from '../../channelTypeUtils';
20
26
  import { canManageChannel, CHANNEL_ROLES } from '../../channelRoleUtils';
21
27
 
28
+ const MemoizedVirtualizer = React.memo(({ scrollRef, startMargin, data, renderItem, overscan = 10 }: any) => (
29
+ <Virtualizer scrollRef={scrollRef} startMargin={startMargin} data={data} overscan={overscan}>
30
+ {renderItem}
31
+ </Virtualizer>
32
+ ));
33
+
34
+ const PinIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /></svg>);
35
+ const UnpinIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" /><path d="M9 15l-4.5 4.5" /><path d="M14.5 4l5.5 5.5" /><line x1="3" y1="3" x2="21" y2="21" /></svg>);
36
+ const SearchIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>);
37
+ const SettingsIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="3"></circle><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path></svg>);
38
+ const DeleteIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"></polyline><path d="M19 6V20a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path></svg>);
39
+ const LeaveIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path><polyline points="16 17 21 12 16 7"></polyline><line x1="21" y1="12" x2="9" y2="12"></line></svg>);
40
+ const CloseTopicIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>);
41
+ const ReopenTopicIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" /></svg>);
42
+ const BlockIcon = () => (<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" /></svg>);
43
+
22
44
  export const DefaultChannelInfoHeader: React.FC<ChannelInfoHeaderProps> = React.memo(({ title, onClose }) => {
23
45
  return (
24
46
  <div className="ermis-channel-info__header">
@@ -93,30 +115,31 @@ export const DefaultChannelInfoCover: React.FC<ChannelInfoCoverProps> = React.me
93
115
  DefaultChannelInfoCover.displayName = 'DefaultChannelInfoCover';
94
116
 
95
117
  export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = React.memo(({
96
- onSearchClick, onSettingsClick, onLeaveChannel, onDeleteChannel,
97
- onBlockUser, onUnblockUser, onCloseTopic, onReopenTopic,
98
- isTeamChannel, isTopic, isClosedTopic, isBlocked, currentUserRole,
99
- searchLabel = 'Search', settingsLabel = 'Settings', deleteLabel = 'Delete', leaveLabel = 'Leave',
100
- blockLabel = 'Block', unblockLabel = 'Unblock', closeTopicLabel = 'Close Topic', reopenTopicLabel = 'Reopen Topic'
118
+ onSearchClick, onSettingsClick, onLeaveChannel, onDeleteChannel, onDeleteTopic, onTruncateChannel,
119
+ onBlockUser, onUnblockUser, onPin, onUnpin, onCloseTopic, onReopenTopic,
120
+ isTeamChannel, isTopic, isClosedTopic, isBlocked, isPinned, currentUserRole,
121
+ searchLabel = 'Search', settingsLabel = 'Settings', deleteLabel = 'Delete', truncateLabel = 'Clear history', leaveLabel = 'Leave',
122
+ blockLabel = 'Block', unblockLabel = 'Unblock', pinLabel = 'Pin', unpinLabel = 'Unpin',
123
+ closeTopicLabel = 'Close Topic', reopenTopicLabel = 'Reopen Topic', deleteTopicLabel = 'Delete Topic'
101
124
  }) => {
102
125
  return (
103
126
  <div className="ermis-channel-info__actions">
104
127
  <button className="ermis-channel-info__action-btn" onClick={onSearchClick} disabled={isBlocked}>
105
128
  <div className="ermis-channel-info__action-icon">
106
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
107
- <circle cx="11" cy="11" r="8"></circle>
108
- <line x1="21" y1="21" x2="16.65" y2="16.65"></line>
109
- </svg>
129
+ <SearchIcon />
110
130
  </div>
111
131
  <span>{searchLabel}</span>
112
132
  </button>
133
+ <button className="ermis-channel-info__action-btn" onClick={isPinned ? onUnpin : onPin} disabled={isBlocked}>
134
+ <div className="ermis-channel-info__action-icon">
135
+ {isPinned ? <UnpinIcon /> : <PinIcon />}
136
+ </div>
137
+ <span>{isPinned ? unpinLabel : pinLabel}</span>
138
+ </button>
113
139
  {isTeamChannel && canManageChannel(currentUserRole) && (
114
140
  <button className="ermis-channel-info__action-btn" onClick={onSettingsClick}>
115
141
  <div className="ermis-channel-info__action-icon">
116
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
117
- <circle cx="12" cy="12" r="3"></circle>
118
- <path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"></path>
119
- </svg>
142
+ <SettingsIcon />
120
143
  </div>
121
144
  <span>{settingsLabel}</span>
122
145
  </button>
@@ -125,21 +148,14 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
125
148
  currentUserRole === CHANNEL_ROLES.OWNER ? (
126
149
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onDeleteChannel}>
127
150
  <div className="ermis-channel-info__action-icon">
128
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
129
- <polyline points="3 6 5 6 21 6"></polyline>
130
- <path d="M19 6V20a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
131
- </svg>
151
+ <DeleteIcon />
132
152
  </div>
133
153
  <span>{deleteLabel}</span>
134
154
  </button>
135
155
  ) : (
136
156
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onLeaveChannel}>
137
157
  <div className="ermis-channel-info__action-icon">
138
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
139
- <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"></path>
140
- <polyline points="16 17 21 12 16 7"></polyline>
141
- <line x1="21" y1="12" x2="9" y2="12"></line>
142
- </svg>
158
+ <LeaveIcon />
143
159
  </div>
144
160
  <span>{leaveLabel}</span>
145
161
  </button>
@@ -150,48 +166,55 @@ export const DefaultChannelInfoActions: React.FC<ChannelInfoActionsProps> = Reac
150
166
  isClosedTopic ? (
151
167
  <button className="ermis-channel-info__action-btn" onClick={onReopenTopic}>
152
168
  <div className="ermis-channel-info__action-icon">
153
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
154
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
155
- <path d="M7 11V7a5 5 0 0 1 9.9-1" />
156
- </svg>
169
+ <ReopenTopicIcon />
157
170
  </div>
158
171
  <span>{reopenTopicLabel}</span>
159
172
  </button>
160
173
  ) : (
161
174
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onCloseTopic}>
162
175
  <div className="ermis-channel-info__action-icon">
163
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
164
- <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
165
- <path d="M7 11V7a5 5 0 0 1 10 0v4" />
166
- </svg>
176
+ <CloseTopicIcon />
167
177
  </div>
168
178
  <span>{closeTopicLabel}</span>
169
179
  </button>
170
180
  )
171
181
  )}
172
- {/* Block/Unblock messaging (1-1) channels only */}
182
+ {/* Topics: Delete Topic for owner */}
183
+ {isTopic && currentUserRole === CHANNEL_ROLES.OWNER && onDeleteTopic && (
184
+ <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onDeleteTopic}>
185
+ <div className="ermis-channel-info__action-icon">
186
+ <DeleteIcon />
187
+ </div>
188
+ <span>{deleteTopicLabel}</span>
189
+ </button>
190
+ )}
191
+ {/* Block/Unblock & Truncate — messaging (1-1) channels only */}
173
192
  {!isTeamChannel && !isTopic && (
174
- isBlocked ? (
193
+ <>
194
+ {onTruncateChannel && (
195
+ <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onTruncateChannel}>
196
+ <div className="ermis-channel-info__action-icon">
197
+ <DeleteIcon />
198
+ </div>
199
+ <span>{truncateLabel}</span>
200
+ </button>
201
+ )}
202
+ {isBlocked ? (
175
203
  <button className="ermis-channel-info__action-btn" onClick={onUnblockUser}>
176
204
  <div className="ermis-channel-info__action-icon">
177
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
178
- <circle cx="12" cy="12" r="10" />
179
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
180
- </svg>
205
+ <BlockIcon />
181
206
  </div>
182
207
  <span>{unblockLabel}</span>
183
208
  </button>
184
209
  ) : (
185
210
  <button className="ermis-channel-info__action-btn ermis-channel-info__action-btn--danger" onClick={onBlockUser}>
186
211
  <div className="ermis-channel-info__action-icon">
187
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
188
- <circle cx="12" cy="12" r="10" />
189
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07" />
190
- </svg>
212
+ <BlockIcon />
191
213
  </div>
192
214
  <span>{blockLabel}</span>
193
215
  </button>
194
- )
216
+ )}
217
+ </>
195
218
  )}
196
219
  </div>
197
220
  );
@@ -205,16 +228,20 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
205
228
  AvatarComponent = Avatar,
206
229
  onClose,
207
230
  title: titleProp,
231
+ isVisible = true,
208
232
  HeaderComponent = DefaultChannelInfoHeader,
209
233
  CoverComponent = DefaultChannelInfoCover,
210
234
  ActionsComponent = DefaultChannelInfoActions,
211
- TabsComponent = DefaultChannelInfoTabs,
235
+ TabHeaderComponent = DefaultChannelInfoTabHeader,
212
236
  AddMemberModalComponent,
213
237
  EditChannelModalComponent,
238
+ EditTopicModalComponent,
214
239
  actionsSearchLabel,
215
240
  actionsSettingsLabel,
216
241
  actionsDeleteLabel,
242
+ actionsTruncateLabel,
217
243
  actionsLeaveLabel,
244
+ actionsCreateTopicLabel,
218
245
  MemberItemComponent,
219
246
  MediaItemComponent,
220
247
  LinkItemComponent,
@@ -224,12 +251,17 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
224
251
  onSearchClick,
225
252
  onLeaveChannel: onLeaveChannelProp,
226
253
  onDeleteChannel: onDeleteChannelProp,
254
+ onTruncateChannel: onTruncateChannelProp,
255
+ onDeleteTopic: onDeleteTopicProp,
256
+ onCreateTopic: onCreateTopicProp,
227
257
  onAddMemberClick,
228
258
  onRemoveMember: onRemoveMemberProp,
229
259
  onBanMember: onBanMemberProp,
230
260
  onUnbanMember: onUnbanMemberProp,
231
261
  onPromoteMember: onPromoteMemberProp,
232
262
  onDemoteMember: onDemoteMemberProp,
263
+ onPinChannel: onPinChannelProp,
264
+ onUnpinChannel: onUnpinChannelProp,
233
265
  // Add Member customization
234
266
  addMemberModalTitle,
235
267
  addMemberSearchPlaceholder,
@@ -239,6 +271,8 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
239
271
  addMemberAddingLabel,
240
272
  addMemberAddedLabel,
241
273
  addMemberButtonLabel,
274
+ MessageSearchPanelComponent,
275
+ ChannelSettingsPanelComponent,
242
276
  AddMemberButtonComponent,
243
277
  // Edit Channel customization
244
278
  onEditChannel: onEditChannelProp,
@@ -260,18 +294,27 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
260
294
  onUnblockUser: onUnblockUserProp,
261
295
  actionsBlockLabel,
262
296
  actionsUnblockLabel,
297
+ actionsPinLabel,
298
+ actionsUnpinLabel,
299
+ actionsPinTopicLabel,
300
+ actionsUnpinTopicLabel,
263
301
  actionsCloseTopicLabel,
264
302
  actionsReopenTopicLabel,
303
+ actionsDeleteTopicLabel,
265
304
  // Settings panel customizations
266
305
  settingsWorkspaceTopicsTitle,
267
306
  settingsTopicsFeatureName,
268
307
  settingsTopicsFeatureDescription,
308
+ roleLabels,
269
309
  } = props;
270
310
 
271
311
  const { activeChannel, client } = useChatClient();
312
+ const scrollContainerRef = useRef<HTMLDivElement | null>(null);
313
+
272
314
  const channel = channelProp || activeChannel;
273
315
  const { isBanned } = useBannedState(channel, client?.userID);
274
316
  const { isBlocked } = useBlockedState(channel, client?.userID);
317
+ const { isPreviewMode } = usePreviewState(channel, client?.userID);
275
318
 
276
319
  const currentUserId = client?.userID;
277
320
  const currentUserRole = currentUserId ? channel?.state?.members?.[currentUserId]?.channel_role : undefined;
@@ -350,6 +393,18 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
350
393
  try { await channel.unblockUser(); } catch (e) { console.error('Error unblocking user', e); }
351
394
  }, [channel, onUnblockUserProp]);
352
395
 
396
+ const handlePinChannel = useCallback(async () => {
397
+ if (onPinChannelProp) return onPinChannelProp();
398
+ if (!channel) return;
399
+ try { await channel.pin(); } catch (e) { console.error('Error pinning channel', e); }
400
+ }, [channel, onPinChannelProp]);
401
+
402
+ const handleUnpinChannel = useCallback(async () => {
403
+ if (onUnpinChannelProp) return onUnpinChannelProp();
404
+ if (!channel) return;
405
+ try { await channel.unpin(); } catch (e) { console.error('Error unpanning channel', e); }
406
+ }, [channel, onUnpinChannelProp]);
407
+
353
408
  const handleCloseTopic = useCallback(async () => {
354
409
  if (!channel || !parentChannel) return;
355
410
  try { await parentChannel.closeTopic(channel.cid); } catch (e) { console.error('Error closing topic', e); }
@@ -360,8 +415,14 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
360
415
  try { await parentChannel.reopenTopic(channel.cid); } catch (e) { console.error('Error reopening topic', e); }
361
416
  }, [channel, parentChannel]);
362
417
 
418
+ const handleDeleteTopic = useCallback(async () => {
419
+ if (onDeleteTopicProp) return onDeleteTopicProp(channel as any);
420
+ if (!channel) return;
421
+ try { await channel.delete(); } catch (e) { console.error('Error deleting topic', e); }
422
+ }, [channel, onDeleteTopicProp]);
423
+
363
424
  const { members } = useChannelMembers(channel);
364
- const { channelName: profileChannelName, channelImage, channelDescription } = useChannelProfile(channel);
425
+ const { channelName: profileChannelName, channelImage, channelDescription, isPinned } = useChannelProfile(channel);
365
426
 
366
427
  let finalChannelName = profileChannelName;
367
428
  let finalParentChannelName = parentChannelName;
@@ -397,161 +458,243 @@ export const ChannelInfo: React.FC<ChannelInfoProps> = React.memo((props) => {
397
458
  setShowAddMemberModal(true);
398
459
  }, [onAddMemberClick]);
399
460
 
400
-
461
+ // virtualizer anchors for page-level virtualization
462
+ const virtualizerAnchorRef = useRef<HTMLDivElement | null>(null);
463
+ const [startMargin, setStartMargin] = useState(0);
464
+
465
+ // Use the headless tabs hook
466
+ const tabs = useChannelInfoTabs({
467
+ channel: channel as any,
468
+ members: members as any,
469
+ AvatarComponent,
470
+ currentUserId,
471
+ currentUserRole,
472
+ onAddMemberClick: isTeamChannel && !isPreviewMode ? handleAddMemberClick : undefined,
473
+ onRemoveMember: handleRemoveMember,
474
+ onBanMember: handleBanMember,
475
+ onUnbanMember: handleUnbanMember,
476
+ onPromoteMember: handlePromoteMember,
477
+ onDemoteMember: handleDemoteMember,
478
+ addMemberButtonLabel,
479
+ AddMemberButtonComponent,
480
+ MemberItemComponent,
481
+ MediaItemComponent,
482
+ LinkItemComponent,
483
+ FileItemComponent,
484
+ EmptyStateComponent,
485
+ LoadingComponent,
486
+ isVisible,
487
+ isPreviewMode,
488
+ roleLabels,
489
+ });
490
+
491
+ // Calculate startMargin for Virtualizer (distance from body top to virtualizer anchor)
492
+ useEffect(() => {
493
+ const body = scrollContainerRef.current;
494
+ const anchor = virtualizerAnchorRef.current;
495
+ if (!body || !anchor) return;
496
+ const bodyRect = body.getBoundingClientRect();
497
+ const anchorRect = anchor.getBoundingClientRect();
498
+ setStartMargin(anchorRect.top - bodyRect.top + body.scrollTop);
499
+ }, [isVisible, tabs.activeTab, isBanned, isPreviewMode]);
401
500
 
402
501
  if (!channel) return null;
403
502
 
404
503
  return (
405
504
  <div className={`ermis-channel-info ${className}`.trim()}>
406
505
  <HeaderComponent title={title} onClose={onClose} />
506
+ <div className="ermis-channel-info__body" ref={scrollContainerRef}>
507
+ <CoverComponent
508
+ channelName={finalChannelName}
509
+ channelImage={channelImage}
510
+ channelDescription={channelDescription}
511
+ AvatarComponent={AvatarComponent}
512
+ canEdit={canEditChannel}
513
+ onEditClick={handleEditChannelClick}
514
+ isPublic={Boolean(channel?.data?.public)}
515
+ isTeamChannel={isTeamChannel}
516
+ parentChannelName={finalParentChannelName}
517
+ isTopic={isTopic}
518
+ />
407
519
 
408
- <CoverComponent
409
- channelName={finalChannelName}
410
- channelImage={channelImage}
411
- channelDescription={channelDescription}
412
- AvatarComponent={AvatarComponent}
413
- canEdit={canEditChannel}
414
- onEditClick={handleEditChannelClick}
415
- isPublic={Boolean(channel?.data?.public)}
416
- isTeamChannel={isTeamChannel}
417
- parentChannelName={finalParentChannelName}
418
- isTopic={isTopic}
419
- />
420
-
421
- {isBanned && (
422
- <div className="ermis-channel-info__banned-banner">
423
- <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
424
- <circle cx="12" cy="12" r="10"></circle>
425
- <line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
426
- </svg>
427
- <span className="ermis-channel-info__banned-banner-text">You have been banned from this channel</span>
428
- </div>
429
- )}
430
- {!isBanned && (
431
- <>
432
- <ActionsComponent
433
- onSearchClick={() => setShowSearchPanel(true)}
434
- onSettingsClick={() => setShowSettingsPanel(true)}
435
- onLeaveChannel={handleLeaveChannel}
436
- onDeleteChannel={handleDeleteChannel}
437
- onBlockUser={handleBlockUser}
438
- onUnblockUser={handleUnblockUser}
439
- onCloseTopic={handleCloseTopic}
440
- onReopenTopic={handleReopenTopic}
441
- isTeamChannel={isTeamChannel}
442
- isTopic={isTopic}
443
- isClosedTopic={isClosedTopic}
444
- isBlocked={isBlocked}
445
- currentUserRole={currentUserRole}
446
- searchLabel={actionsSearchLabel}
447
- settingsLabel={actionsSettingsLabel}
448
- deleteLabel={actionsDeleteLabel}
449
- leaveLabel={actionsLeaveLabel}
450
- blockLabel={actionsBlockLabel}
451
- unblockLabel={actionsUnblockLabel}
452
- closeTopicLabel={actionsCloseTopicLabel}
453
- reopenTopicLabel={actionsReopenTopicLabel}
454
- />
455
-
456
- <TabsComponent
457
- channel={channel}
458
- members={members as any}
459
- AvatarComponent={AvatarComponent}
460
- currentUserId={currentUserId}
461
- currentUserRole={currentUserRole}
462
- onAddMemberClick={isTeamChannel ? handleAddMemberClick : undefined}
463
- onRemoveMember={handleRemoveMember}
464
- onBanMember={handleBanMember}
465
- onUnbanMember={handleUnbanMember}
466
- onPromoteMember={handlePromoteMember}
467
- onDemoteMember={handleDemoteMember}
468
- addMemberButtonLabel={addMemberButtonLabel}
469
- AddMemberButtonComponent={AddMemberButtonComponent}
470
- MemberItemComponent={MemberItemComponent}
471
- MediaItemComponent={MediaItemComponent}
472
- LinkItemComponent={LinkItemComponent}
473
- FileItemComponent={FileItemComponent}
474
- EmptyStateComponent={EmptyStateComponent}
475
- LoadingComponent={LoadingComponent}
476
- />
477
-
478
- {showAddMemberModal && (() => {
479
- const ModalComp = AddMemberModalComponent || AddMemberModal;
480
- return (
481
- <ModalComp
482
- channel={channel}
483
- currentMembers={members as any}
484
- onClose={() => setShowAddMemberModal(false)}
485
- AvatarComponent={AvatarComponent}
486
- title={addMemberModalTitle}
487
- searchPlaceholder={addMemberSearchPlaceholder}
488
- loadingText={addMemberLoadingText}
489
- emptyText={addMemberEmptyText}
490
- addLabel={addMemberAddLabel}
491
- addingLabel={addMemberAddingLabel}
492
- addedLabel={addMemberAddedLabel}
493
- />
494
- );
495
- })()}
496
-
497
- {showEditChannelModal && (() => {
498
- const EditComp = EditChannelModalComponent || EditChannelModal;
499
- return (
500
- <EditComp
501
- channel={channel}
502
- onClose={() => setShowEditChannelModal(false)}
503
- onSave={onEditChannelProp}
504
- AvatarComponent={AvatarComponent}
505
- title={editChannelModalTitle}
506
- nameLabel={editChannelNameLabel}
507
- descriptionLabel={editChannelDescriptionLabel}
508
- namePlaceholder={editChannelNamePlaceholder}
509
- descriptionPlaceholder={editChannelDescriptionPlaceholder}
510
- publicLabel={editChannelPublicLabel}
511
- saveLabel={editChannelSaveLabel}
512
- cancelLabel={editChannelCancelLabel}
513
- savingLabel={editChannelSavingLabel}
514
- changeAvatarLabel={editChannelChangeAvatarLabel}
515
- imageAccept={editChannelImageAccept}
516
- maxImageSize={editChannelMaxImageSize}
517
- maxImageSizeError={editChannelMaxImageSizeError}
520
+ {isBanned && (
521
+ <div className="ermis-channel-info__banned-banner">
522
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
523
+ <circle cx="12" cy="12" r="10"></circle>
524
+ <line x1="4.93" y1="4.93" x2="19.07" y2="19.07"></line>
525
+ </svg>
526
+ <span className="ermis-channel-info__banned-banner-text">You have been banned from this channel</span>
527
+ </div>
528
+ )}
529
+ {!isBanned && isPreviewMode && (
530
+ <div className="ermis-channel-info__preview-actions">
531
+ <button
532
+ className="ermis-channel-info__join-btn"
533
+ onClick={() => channel?.acceptInvite('join').catch(e => console.error('Failed to join public channel', e))}
534
+ >
535
+ Join Channel
536
+ </button>
537
+ </div>
538
+ )}
539
+ {!isBanned && (
540
+ <>
541
+ {!isPreviewMode && (
542
+ <ActionsComponent
543
+ onSearchClick={() => setShowSearchPanel(true)}
544
+ onSettingsClick={() => setShowSettingsPanel(true)}
545
+ onLeaveChannel={handleLeaveChannel}
546
+ onDeleteChannel={handleDeleteChannel}
547
+ onTruncateChannel={onTruncateChannelProp ? () => onTruncateChannelProp(channel) : undefined}
548
+ onBlockUser={handleBlockUser}
549
+ onUnblockUser={handleUnblockUser}
550
+ onPin={handlePinChannel}
551
+ onUnpin={handleUnpinChannel}
552
+ onCloseTopic={handleCloseTopic}
553
+ onReopenTopic={handleReopenTopic}
554
+ onDeleteTopic={handleDeleteTopic}
555
+ onCreateTopic={onCreateTopicProp ? () => onCreateTopicProp(channel) : undefined}
556
+ isTeamChannel={isTeamChannel}
557
+ isTopic={isTopic}
558
+ isClosedTopic={isClosedTopic}
559
+ isBlocked={isBlocked}
560
+ isPinned={isPinned}
561
+ topicsEnabled={channel?.data?.topics_enabled === true}
562
+ currentUserRole={currentUserRole}
563
+ searchLabel={actionsSearchLabel}
564
+ settingsLabel={actionsSettingsLabel}
565
+ deleteLabel={actionsDeleteLabel}
566
+ truncateLabel={actionsTruncateLabel}
567
+ leaveLabel={actionsLeaveLabel}
568
+ blockLabel={actionsBlockLabel}
569
+ unblockLabel={actionsUnblockLabel}
570
+ pinLabel={isTopic ? (actionsPinTopicLabel || 'Pin topic') : (actionsPinLabel || 'Pin channel')}
571
+ unpinLabel={isTopic ? (actionsUnpinTopicLabel || 'Unpin topic') : (actionsUnpinLabel || 'Unpin channel')}
572
+ closeTopicLabel={actionsCloseTopicLabel}
573
+ reopenTopicLabel={actionsReopenTopicLabel}
574
+ deleteTopicLabel={actionsDeleteTopicLabel}
575
+ createTopicLabel={actionsCreateTopicLabel}
518
576
  />
519
- );
520
- })()}
521
-
522
- {showEditTopicModal && (() => {
523
- return (
524
- <TopicModal
525
- isOpen={true}
526
- onClose={() => setShowEditTopicModal(false)}
527
- topic={channel}
577
+ )}
578
+
579
+ <TabHeaderComponent
580
+ activeTab={tabs.activeTab}
581
+ onTabChange={tabs.handleTabChange}
582
+ availableTabs={tabs.availableTabs}
583
+ tabCounts={{} as any}
584
+ />
585
+
586
+ <div
587
+ ref={virtualizerAnchorRef}
588
+ className="ermis-channel-info__media-content"
589
+ style={tabs.isPending ? PENDING_STYLE : READY_STYLE}
590
+ >
591
+ {tabs.isPending || (tabs.loading && tabs.contentTab !== 'members') ? (
592
+ <tabs.Loading tab={tabs.activeTab} />
593
+ ) : tabs.isTabEmpty ? (
594
+ <tabs.EmptyState label={tabs.emptyLabel} />
595
+ ) : (
596
+ <MemoizedVirtualizer
597
+ scrollRef={scrollContainerRef}
598
+ startMargin={startMargin}
599
+ data={tabs.vlistData}
600
+ renderItem={tabs.renderVlistItem}
601
+ />
602
+ )}
603
+ </div>
604
+
605
+ {/* Media Lightbox */}
606
+ {tabs.lightboxItems.length > 0 && (
607
+ <MediaLightbox
608
+ items={tabs.lightboxItems}
609
+ initialIndex={tabs.lightboxIndex}
610
+ isOpen={tabs.lightboxOpen}
611
+ onClose={tabs.closeLightbox}
528
612
  />
529
- );
530
- })()}
531
- </>
532
- )}
613
+ )}
614
+
615
+ {showAddMemberModal && (() => {
616
+ const ModalComp = AddMemberModalComponent || AddMemberModal;
617
+ return (
618
+ <ModalComp
619
+ channel={channel}
620
+ currentMembers={members as any}
621
+ onClose={() => setShowAddMemberModal(false)}
622
+ AvatarComponent={AvatarComponent}
623
+ title={addMemberModalTitle}
624
+ searchPlaceholder={addMemberSearchPlaceholder}
625
+ loadingText={addMemberLoadingText}
626
+ emptyText={addMemberEmptyText}
627
+ addLabel={addMemberAddLabel}
628
+ />
629
+ );
630
+ })()}
631
+
632
+ {showEditChannelModal && (() => {
633
+ const ModalComp = EditChannelModalComponent || EditChannelModal;
634
+ return (
635
+ <ModalComp
636
+ channel={channel}
637
+ onClose={() => setShowEditChannelModal(false)}
638
+ onSave={onEditChannelProp}
639
+ AvatarComponent={AvatarComponent}
640
+ title={editChannelModalTitle}
641
+ nameLabel={editChannelNameLabel}
642
+ descriptionLabel={editChannelDescriptionLabel}
643
+ namePlaceholder={editChannelNamePlaceholder}
644
+ descriptionPlaceholder={editChannelDescriptionPlaceholder}
645
+ publicLabel={editChannelPublicLabel}
646
+ saveLabel={editChannelSaveLabel}
647
+ cancelLabel={editChannelCancelLabel}
648
+ savingLabel={editChannelSavingLabel}
649
+ changeAvatarLabel={editChannelChangeAvatarLabel}
650
+ imageAccept={editChannelImageAccept}
651
+ maxImageSize={editChannelMaxImageSize}
652
+ maxImageSizeError={editChannelMaxImageSizeError}
653
+ />
654
+ );
655
+ })()}
656
+
657
+ {showEditTopicModal && (() => {
658
+ const ModalComp = EditTopicModalComponent || TopicModal;
659
+ return (
660
+ <ModalComp
661
+ isOpen={true}
662
+ onClose={() => setShowEditTopicModal(false)}
663
+ topic={channel}
664
+ />
665
+ );
666
+ })()}
667
+ </>
668
+ )}
669
+ </div>
533
670
 
534
671
  {/* Search Panel — slides over entire ChannelInfo body */}
535
- {channel && showSearchPanel && (
536
- <MessageSearchPanel
537
- isOpen={showSearchPanel}
538
- onClose={() => setShowSearchPanel(false)}
539
- channel={channel}
540
- AvatarComponent={AvatarComponent}
541
- />
542
- )}
672
+ {channel && showSearchPanel && (() => {
673
+ const SearchPanel = MessageSearchPanelComponent || MessageSearchPanel;
674
+ return (
675
+ <SearchPanel
676
+ isOpen={showSearchPanel}
677
+ onClose={() => setShowSearchPanel(false)}
678
+ channel={channel}
679
+ AvatarComponent={AvatarComponent}
680
+ />
681
+ );
682
+ })()}
543
683
 
544
684
  {/* Settings Panel — slides over entire ChannelInfo body */}
545
- {channel && showSettingsPanel && (
546
- <ChannelSettingsPanel
547
- isOpen={showSettingsPanel}
548
- onClose={() => setShowSettingsPanel(false)}
549
- channel={channel}
550
- workspaceTopicsTitle={settingsWorkspaceTopicsTitle}
551
- topicsFeatureName={settingsTopicsFeatureName}
552
- topicsFeatureDescription={settingsTopicsFeatureDescription}
553
- />
554
- )}
685
+ {channel && showSettingsPanel && (() => {
686
+ const SettingsPanel = ChannelSettingsPanelComponent || ChannelSettingsPanel;
687
+ return (
688
+ <SettingsPanel
689
+ isOpen={showSettingsPanel}
690
+ onClose={() => setShowSettingsPanel(false)}
691
+ channel={channel}
692
+ workspaceTopicsTitle={settingsWorkspaceTopicsTitle}
693
+ topicsFeatureName={settingsTopicsFeatureName}
694
+ topicsFeatureDescription={settingsTopicsFeatureDescription}
695
+ />
696
+ );
697
+ })()}
555
698
  </div>
556
699
  );
557
700
  });