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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +144 -0
  2. package/dist/index.cjs +8320 -3427
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.css +1277 -291
  5. package/dist/index.css.map +1 -1
  6. package/dist/index.d.mts +1131 -99
  7. package/dist/index.d.ts +1131 -99
  8. package/dist/index.mjs +8168 -3319
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +9 -4
  11. package/src/channelTypeUtils.ts +1 -1
  12. package/src/components/Avatar.tsx +2 -1
  13. package/src/components/Channel.tsx +6 -5
  14. package/src/components/ChannelActions.tsx +67 -3
  15. package/src/components/ChannelHeader.tsx +27 -37
  16. package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
  17. package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
  18. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
  19. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
  20. package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
  21. package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
  22. package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
  23. package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
  24. package/src/components/ChannelInfo/States.tsx +1 -1
  25. package/src/components/ChannelInfo/index.ts +3 -0
  26. package/src/components/ChannelInfo/useChannelInfoTabs.tsx +427 -0
  27. package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
  28. package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
  29. package/src/components/ChannelList.tsx +247 -301
  30. package/src/components/CreateChannelModal.tsx +290 -93
  31. package/src/components/Dropdown.tsx +1 -16
  32. package/src/components/EditPreview.tsx +1 -0
  33. package/src/components/ErmisCallProvider.tsx +72 -17
  34. package/src/components/ErmisCallUI.tsx +43 -20
  35. package/src/components/FilesPreview.tsx +8 -12
  36. package/src/components/FlatTopicGroupItem.tsx +243 -0
  37. package/src/components/ForwardMessageModal.tsx +43 -81
  38. package/src/components/MediaLightbox.tsx +454 -292
  39. package/src/components/MentionSuggestions.tsx +47 -35
  40. package/src/components/MessageActionsBox.tsx +6 -1
  41. package/src/components/MessageInput.tsx +165 -17
  42. package/src/components/MessageInputDefaults.tsx +127 -1
  43. package/src/components/MessageItem.tsx +155 -43
  44. package/src/components/MessageQuickReactions.tsx +153 -23
  45. package/src/components/MessageReactions.tsx +49 -3
  46. package/src/components/MessageRenderers.tsx +1114 -445
  47. package/src/components/Panel.tsx +1 -14
  48. package/src/components/PinnedMessages.tsx +55 -15
  49. package/src/components/PreviewOverlay.tsx +24 -0
  50. package/src/components/QuotedMessagePreview.tsx +99 -8
  51. package/src/components/ReadReceipts.tsx +2 -1
  52. package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
  53. package/src/components/RecoveryPin/index.ts +19 -0
  54. package/src/components/TopicList.tsx +236 -0
  55. package/src/components/TopicModal.tsx +4 -1
  56. package/src/components/TypingIndicator.tsx +17 -8
  57. package/src/components/UserPicker.tsx +94 -16
  58. package/src/components/VirtualMessageList.tsx +419 -113
  59. package/src/context/ChatComponentsContext.tsx +14 -0
  60. package/src/context/ChatProvider.tsx +44 -14
  61. package/src/context/ErmisCallContext.tsx +4 -0
  62. package/src/hooks/useChannelCapabilities.ts +7 -4
  63. package/src/hooks/useChannelData.ts +10 -3
  64. package/src/hooks/useChannelListUpdates.ts +94 -21
  65. package/src/hooks/useChannelMessages.ts +391 -42
  66. package/src/hooks/useChannelRowUpdates.ts +36 -5
  67. package/src/hooks/useChatUser.ts +39 -0
  68. package/src/hooks/useContactChannels.ts +45 -0
  69. package/src/hooks/useContactCount.ts +50 -0
  70. package/src/hooks/useDownloadHandler.ts +36 -0
  71. package/src/hooks/useDragAndDrop.ts +79 -0
  72. package/src/hooks/useE2eeAttachmentRenderer.ts +204 -0
  73. package/src/hooks/useE2eeFileUpload.ts +38 -0
  74. package/src/hooks/useFileUpload.ts +25 -5
  75. package/src/hooks/useForwardMessage.ts +309 -0
  76. package/src/hooks/useInviteChannels.ts +88 -0
  77. package/src/hooks/useInviteCount.ts +104 -0
  78. package/src/hooks/useLoadMessages.ts +16 -4
  79. package/src/hooks/useMentions.ts +60 -7
  80. package/src/hooks/useMessageActions.ts +19 -10
  81. package/src/hooks/useMessageSend.ts +64 -12
  82. package/src/hooks/usePendingE2eeSends.ts +29 -0
  83. package/src/hooks/usePendingState.ts +21 -4
  84. package/src/hooks/usePreviewState.ts +69 -0
  85. package/src/hooks/useRecoveryPin.ts +287 -0
  86. package/src/hooks/useScrollToMessage.ts +29 -4
  87. package/src/hooks/useStickerPicker.ts +62 -0
  88. package/src/hooks/useTopicGroupUpdates.ts +235 -0
  89. package/src/index.ts +79 -6
  90. package/src/messageTypeUtils.ts +27 -1
  91. package/src/styles/_base.css +0 -1
  92. package/src/styles/_call-ui.css +59 -2
  93. package/src/styles/_channel-info.css +50 -4
  94. package/src/styles/_channel-list.css +131 -68
  95. package/src/styles/_create-channel-modal.css +10 -0
  96. package/src/styles/_forward-modal.css +16 -1
  97. package/src/styles/_media-lightbox.css +67 -2
  98. package/src/styles/_mentions.css +1 -1
  99. package/src/styles/_message-actions.css +3 -4
  100. package/src/styles/_message-bubble.css +631 -112
  101. package/src/styles/_message-input.css +139 -0
  102. package/src/styles/_message-list.css +91 -18
  103. package/src/styles/_message-quick-reactions.css +105 -32
  104. package/src/styles/_message-reactions.css +22 -32
  105. package/src/styles/_modal.css +2 -1
  106. package/src/styles/_preview-overlay.css +38 -0
  107. package/src/styles/_recovery-pin.css +97 -0
  108. package/src/styles/_tokens.css +22 -20
  109. package/src/styles/_typing-indicator.css +26 -10
  110. package/src/styles/index.css +2 -0
  111. package/src/types.ts +477 -15
  112. package/src/utils/avatarColors.ts +48 -0
  113. package/src/utils.ts +219 -16
@@ -0,0 +1,427 @@
1
+ import React, { useState, useEffect, useMemo, useCallback, startTransition, useRef } from 'react';
2
+ import { ROLE_WEIGHTS, MESSAGING_TABS, ALL_TABS } from './utils';
3
+ import { useBannedState } from '../../hooks/useBannedState';
4
+ import { useBlockedState } from '../../hooks/useBlockedState';
5
+ import { MediaGridItem, MediaRow } from './MediaGridItem';
6
+ import { LinkListItem } from './LinkListItem';
7
+ import { FileListItem } from './FileListItem';
8
+ import { MemberListItem } from './MemberListItem';
9
+ import { TabEmptyState, TabLoadingState } from './States';
10
+ import { useDownloadHandler } from '../../hooks/useDownloadHandler';
11
+ import type { ChannelInfoTabsProps, MediaTab, AttachmentItem, MediaLightboxItem } from '../../types';
12
+ import { isDirectChannel } from '../../channelTypeUtils';
13
+ import {
14
+ CHANNEL_ROLES,
15
+ canRemoveTargetMember,
16
+ canBanTargetMember,
17
+ canPromoteTargetMember,
18
+ canDemoteTargetMember,
19
+ } from '../../channelRoleUtils';
20
+
21
+ export const useChannelInfoTabs = (props: ChannelInfoTabsProps) => {
22
+ const {
23
+ channel,
24
+ members,
25
+ AvatarComponent,
26
+ currentUserId,
27
+ currentUserRole,
28
+ onAddMemberClick,
29
+ onRemoveMember,
30
+ onBanMember,
31
+ onUnbanMember,
32
+ onPromoteMember,
33
+ onDemoteMember,
34
+ addMemberButtonLabel = 'Add Member',
35
+ AddMemberButtonComponent,
36
+ MemberItemComponent,
37
+ MediaItemComponent,
38
+ LinkItemComponent,
39
+ FileItemComponent,
40
+ EmptyStateComponent,
41
+ LoadingComponent,
42
+ isVisible = true,
43
+ isPreviewMode = false,
44
+ } = props;
45
+
46
+ const isMessaging = isDirectChannel(channel);
47
+ const isTopic = Boolean(channel?.data?.parent_cid);
48
+
49
+ const { isBanned } = useBannedState(channel, currentUserId);
50
+ const { isBlocked } = useBlockedState(channel, currentUserId);
51
+
52
+ const availableTabs: MediaTab[] = useMemo(() => {
53
+ let tabs = isMessaging ? MESSAGING_TABS : ALL_TABS;
54
+ if (isTopic) {
55
+ tabs = tabs.filter((t) => t !== 'members');
56
+ }
57
+ return tabs;
58
+ }, [isMessaging, isTopic]);
59
+
60
+ const [activeTab, setActiveTab] = useState<MediaTab>(availableTabs[0]);
61
+ const [contentTab, setContentTab] = useState<MediaTab>(availableTabs[0]);
62
+ const [isPending, setIsPending] = useState(false);
63
+
64
+ const [attachmentsFetchedForCid, setAttachmentsFetchedForCid] = useState<string | null>(null);
65
+ const lastFetchedCidRef = useRef<string | null>(null);
66
+ const transitionRafRef = useRef<any>(null);
67
+
68
+ const handleTabChange = useCallback(
69
+ (tab: MediaTab) => {
70
+ if (tab === activeTab) return;
71
+
72
+ // 1. Instant UI update for the tab button
73
+ setActiveTab(tab);
74
+
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;
79
+
80
+ if (hasData) {
81
+ // If data exists, switch content immediately without loading state
82
+ setContentTab(tab);
83
+ setIsPending(false);
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
+ );
96
+
97
+ // Reset tab when user switches channels
98
+ useEffect(() => {
99
+ if (transitionRafRef.current) clearTimeout(transitionRafRef.current);
100
+ setActiveTab(availableTabs[0]);
101
+ setContentTab(availableTabs[0]);
102
+ setIsPending(false);
103
+ setAttachmentsFetchedForCid(null);
104
+ setAllAttachments([]);
105
+ lastFetchedCidRef.current = null;
106
+ // eslint-disable-next-line react-hooks/exhaustive-deps
107
+ }, [channel?.cid, availableTabs]);
108
+
109
+ // Auto-trigger fetch for channels where default tab needs attachment data
110
+ useEffect(() => {
111
+ if (!isVisible) return;
112
+ if (availableTabs[0] === 'members') return;
113
+ const rafId = requestAnimationFrame(() => {
114
+ setAttachmentsFetchedForCid(channel?.cid || null);
115
+ });
116
+ return () => cancelAnimationFrame(rafId);
117
+ // eslint-disable-next-line react-hooks/exhaustive-deps
118
+ }, [channel?.cid, availableTabs, isVisible]);
119
+
120
+ // Resolve sub-components with defaults
121
+ const MemberItem = MemberItemComponent || MemberListItem;
122
+ const MediaItem = MediaItemComponent || MediaGridItem;
123
+ const LinkItem = LinkItemComponent || LinkListItem;
124
+ const FileItem = FileItemComponent || FileListItem;
125
+ const EmptyState = EmptyStateComponent || TabEmptyState;
126
+ const Loading = LoadingComponent || TabLoadingState;
127
+
128
+ const [allAttachments, setAllAttachments] = useState<AttachmentItem[]>([]);
129
+ const [loading, setLoading] = useState(true);
130
+ const [refreshAttachmentsCount, setRefreshAttachmentsCount] = useState(0);
131
+
132
+ const forceRefreshAttachments = useCallback(() => {
133
+ lastFetchedCidRef.current = null;
134
+ setRefreshAttachmentsCount((c) => c + 1);
135
+ }, []);
136
+
137
+ const sortedMembers = useMemo(() => {
138
+ return [...members].sort((a, b) => {
139
+ const aWeight = ROLE_WEIGHTS[a.channel_role || CHANNEL_ROLES.MEMBER] || 0;
140
+ const bWeight = ROLE_WEIGHTS[b.channel_role || CHANNEL_ROLES.MEMBER] || 0;
141
+ return bWeight - aWeight;
142
+ });
143
+ }, [members]);
144
+
145
+ // Categorize attachments by type
146
+ const mediaItems = useMemo(
147
+ () => allAttachments.filter((a) => a.attachment_type === 'image' || a.attachment_type === 'video'),
148
+ [allAttachments],
149
+ );
150
+
151
+ const linkItems = useMemo(() => allAttachments.filter((a) => a.attachment_type === 'linkPreview'), [allAttachments]);
152
+
153
+ const fileItems = useMemo(
154
+ () => allAttachments.filter((a) => a.attachment_type === 'file' || a.attachment_type === 'voiceRecording'),
155
+ [allAttachments],
156
+ );
157
+
158
+ useEffect(() => {
159
+ if (!isVisible) return;
160
+ if (!attachmentsFetchedForCid || attachmentsFetchedForCid !== channel?.cid) {
161
+ setLoading(false);
162
+ return;
163
+ }
164
+ if (lastFetchedCidRef.current === channel?.cid) return;
165
+
166
+ let active = true;
167
+
168
+ if (isBanned || isBlocked || isPreviewMode) {
169
+ setAllAttachments([]);
170
+ setLoading(false);
171
+ return;
172
+ }
173
+
174
+ const fetchMedia = async () => {
175
+ setLoading(true);
176
+ try {
177
+ const response: any = await channel.queryAttachmentMessages();
178
+ if (active) {
179
+ const items = response?.attachments || [];
180
+ startTransition(() => {
181
+ setAllAttachments(items);
182
+ });
183
+ lastFetchedCidRef.current = channel?.cid || null;
184
+ }
185
+ } catch (err) {
186
+ console.error('Failed to query media for channel info', err);
187
+ if (active) setAllAttachments([]);
188
+ } finally {
189
+ if (active) setLoading(false);
190
+ }
191
+ };
192
+
193
+ fetchMedia();
194
+ return () => {
195
+ active = false;
196
+ };
197
+ }, [channel, isBanned, isBlocked, isPreviewMode, attachmentsFetchedForCid, isVisible, refreshAttachmentsCount]);
198
+
199
+ // Listen to realtime events to automatically refresh attachments
200
+ useEffect(() => {
201
+ if (!channel || !isVisible) return;
202
+
203
+ const handleEvent = (event: any) => {
204
+ if (event.message?.attachments && event.message.attachments.length > 0) {
205
+ forceRefreshAttachments();
206
+ }
207
+ };
208
+
209
+ channel.on('message.new', handleEvent);
210
+ channel.on('message.deleted', handleEvent);
211
+ channel.on('message.updated', handleEvent);
212
+
213
+ return () => {
214
+ channel.off('message.new', handleEvent);
215
+ channel.off('message.deleted', handleEvent);
216
+ channel.off('message.updated', handleEvent);
217
+ };
218
+ }, [channel, isVisible, forceRefreshAttachments]);
219
+
220
+ const { downloadFile } = useDownloadHandler();
221
+
222
+ const handleDownloadFile = useCallback(
223
+ async (url: string, filename?: string) => {
224
+ await downloadFile(url, filename);
225
+ },
226
+ [downloadFile],
227
+ );
228
+
229
+ // Lightbox state for media tab
230
+ const [lightboxOpen, setLightboxOpen] = useState(false);
231
+ const [lightboxIndex, setLightboxIndex] = useState(0);
232
+
233
+ const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
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
+ }));
242
+ }, [mediaItems]);
243
+
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
+ );
256
+
257
+ const closeLightbox = useCallback(() => {
258
+ setLightboxOpen(false);
259
+ }, []);
260
+
261
+ // Group media into rows of 3 for grid layout inside VList
262
+ const mediaRows = useMemo(() => {
263
+ const rows: AttachmentItem[][] = [];
264
+ for (let i = 0; i < mediaItems.length; i += 3) {
265
+ rows.push(mediaItems.slice(i, i + 3));
266
+ }
267
+ return rows;
268
+ }, [mediaItems]);
269
+
270
+ // Build VList data array based on contentTab (deferred)
271
+ const vlistData = useMemo(() => {
272
+ switch (contentTab) {
273
+ case 'members': {
274
+ const items: any[] = [];
275
+ if (onAddMemberClick) {
276
+ items.push({ type: 'add-member' });
277
+ }
278
+ sortedMembers.forEach((member) => {
279
+ items.push({ type: 'member', data: member });
280
+ });
281
+ return items;
282
+ }
283
+ case 'media':
284
+ return mediaRows.map((row) => ({ type: 'media-row', data: row }));
285
+ case 'links':
286
+ return linkItems.map((item) => ({ type: 'link', data: item }));
287
+ case 'files':
288
+ return fileItems.map((item) => ({ type: 'file', data: item }));
289
+ default:
290
+ return [];
291
+ }
292
+ }, [contentTab, sortedMembers, mediaRows, mediaItems, linkItems, fileItems, onAddMemberClick]);
293
+
294
+ // Render function for VList items
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
+ }
306
+ return (
307
+ <div key="__add-member__" className="ermis-channel-info__add-member-wrap">
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>
326
+ </div>
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
+ );
359
+ }
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;
382
+ }
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
+ );
404
+
405
+ const isTabEmpty = vlistData.length === 0 && !(loading && contentTab !== 'members');
406
+ const emptyLabel = contentTab === 'members' ? 'members' : contentTab;
407
+
408
+ return {
409
+ availableTabs,
410
+ activeTab,
411
+ contentTab,
412
+ handleTabChange,
413
+ isPending,
414
+ loading,
415
+ isTabEmpty,
416
+ emptyLabel,
417
+ vlistData,
418
+ renderVlistItem,
419
+ lightboxItems,
420
+ lightboxOpen,
421
+ lightboxIndex,
422
+ handleMediaClick,
423
+ closeLightbox,
424
+ EmptyState,
425
+ Loading,
426
+ };
427
+ };
@@ -0,0 +1,212 @@
1
+ import { useState, useEffect, useCallback } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+
4
+ export interface UseChannelSettingsOptions {
5
+ channel: Channel | undefined;
6
+ isOpen?: boolean;
7
+ onClose?: () => void;
8
+ currentUserRole?: string;
9
+ }
10
+
11
+ export const useChannelSettings = ({ channel, isOpen, onClose, currentUserRole }: UseChannelSettingsOptions) => {
12
+ const [slowMode, setSlowMode] = useState<number>(0);
13
+ const [topicsEnabled, setTopicsEnabled] = useState<boolean>(false);
14
+ const [capabilities, setCapabilities] = useState<Record<string, boolean>>({
15
+ 'send-message': true,
16
+ 'send-links': true,
17
+ 'update-own-message': true,
18
+ 'delete-own-message': true,
19
+ 'send-reaction': true,
20
+ 'pin-message': true,
21
+ 'create-poll': true,
22
+ 'vote-poll': true,
23
+ });
24
+
25
+ const [keywords, setKeywords] = useState<string[]>([]);
26
+ const [newKeyword, setNewKeyword] = useState('');
27
+ const [isSaving, setIsSaving] = useState(false);
28
+ const [error, setError] = useState<string | null>(null);
29
+
30
+ const isOwner = currentUserRole === 'owner';
31
+
32
+ // Sync state when panel opens or channel updates
33
+ useEffect(() => {
34
+ if (!channel) return;
35
+
36
+ const syncData = (dataToSync = channel.data) => {
37
+ setSlowMode((dataToSync?.member_message_cooldown as number) || 0);
38
+ setKeywords((dataToSync?.filter_words as string[]) || []);
39
+ setTopicsEnabled(dataToSync?.topics_enabled === true);
40
+
41
+ const caps = dataToSync?.member_capabilities as string[] || [];
42
+ setCapabilities({
43
+ 'send-message': caps.includes('send-message'),
44
+ 'send-links': caps.includes('send-links'),
45
+ 'update-own-message': caps.includes('update-own-message'),
46
+ 'delete-own-message': caps.includes('delete-own-message'),
47
+ 'send-reaction': caps.includes('send-reaction'),
48
+ 'pin-message': caps.includes('pin-message'),
49
+ 'create-poll': caps.includes('create-poll'),
50
+ 'vote-poll': caps.includes('vote-poll'),
51
+ });
52
+ setError(null);
53
+ };
54
+
55
+ if (isOpen) {
56
+ syncData();
57
+ }
58
+
59
+ // Listen to real-time changes
60
+ const subscription = channel.on('channel.updated', (event: any) => {
61
+ const latestData = event?.channel || channel.data;
62
+ // Force mutating local channel.data to ensure future syncData hits cache
63
+ if (event?.channel && channel.data) {
64
+ Object.assign(channel.data, event.channel);
65
+ }
66
+
67
+ if (isOpen) {
68
+ syncData(latestData);
69
+ }
70
+ });
71
+
72
+ return () => {
73
+ subscription?.unsubscribe();
74
+ };
75
+ }, [isOpen, channel]);
76
+
77
+ const toggleCapability = useCallback((key: string) => {
78
+ setCapabilities(prev => ({ ...prev, [key]: !prev[key] }));
79
+ }, []);
80
+
81
+ // Compute dirty state
82
+ const isSlowModeChanged = slowMode !== ((channel?.data?.member_message_cooldown as number) || 0);
83
+ const isTopicsChanged = topicsEnabled !== (channel?.data?.topics_enabled === true);
84
+
85
+ const currentKeywordsSorted = [...keywords].sort().join(',');
86
+ const originalKeywordsSorted = [...((channel?.data?.filter_words as string[]) || [])].sort().join(',');
87
+ const isKeywordsChanged = currentKeywordsSorted !== originalKeywordsSorted;
88
+
89
+ const originalCapabilities = channel?.data?.member_capabilities as string[] || [];
90
+ const initialCapabilities: Record<string, boolean> = {
91
+ 'send-message': originalCapabilities.includes('send-message'),
92
+ 'send-links': originalCapabilities.includes('send-links'),
93
+ 'update-own-message': originalCapabilities.includes('update-own-message'),
94
+ 'delete-own-message': originalCapabilities.includes('delete-own-message'),
95
+ 'send-reaction': originalCapabilities.includes('send-reaction'),
96
+ 'pin-message': originalCapabilities.includes('pin-message'),
97
+ 'create-poll': originalCapabilities.includes('create-poll'),
98
+ 'vote-poll': originalCapabilities.includes('vote-poll'),
99
+ };
100
+ const isCapabilitiesChanged = Object.keys(capabilities).some(k => capabilities[k] !== initialCapabilities[k]);
101
+
102
+ const isDirty = isSlowModeChanged || isKeywordsChanged || isCapabilitiesChanged || isTopicsChanged;
103
+
104
+ const handleAddNewKeyword = useCallback(() => {
105
+ if (newKeyword.trim()) {
106
+ const keyword = newKeyword.trim().toLowerCase();
107
+ if (!keywords.includes(keyword)) {
108
+ setKeywords(prev => [...prev, keyword]);
109
+ }
110
+ setNewKeyword('');
111
+ }
112
+ }, [newKeyword, keywords]);
113
+
114
+ const handleRemoveKeyword = useCallback((kw: string) => {
115
+ setKeywords(prev => prev.filter(k => k !== kw));
116
+ }, []);
117
+
118
+ const handleSave = useCallback(async () => {
119
+ if (!channel) return;
120
+ setIsSaving(true);
121
+ setError(null);
122
+ try {
123
+ const dataUpdates: any = {};
124
+ let capabilitiesArray: string[] | null = null;
125
+
126
+ if (isSlowModeChanged) {
127
+ dataUpdates.member_message_cooldown = slowMode;
128
+ }
129
+
130
+ if (isKeywordsChanged) {
131
+ dataUpdates.filter_words = keywords;
132
+ }
133
+
134
+ if (isCapabilitiesChanged) {
135
+ const controlledKeys = Object.keys(capabilities);
136
+ const originalCaps = (channel.data?.member_capabilities as string[]) || [];
137
+
138
+ // Preserve unmanaged original capabilities
139
+ const unmanagedCaps = originalCaps.filter(c => !controlledKeys.includes(c));
140
+
141
+ // Extract managed capabilities that are currently enabled
142
+ const managedEnabledCaps = controlledKeys.filter(k => capabilities[k as keyof typeof capabilities]);
143
+
144
+ // Merge into the final payload array
145
+ capabilitiesArray = [...unmanagedCaps, ...managedEnabledCaps];
146
+ }
147
+
148
+ if (Object.keys(dataUpdates).length > 0 || capabilitiesArray !== null) {
149
+ const payload: any = {};
150
+
151
+ if (Object.keys(dataUpdates).length > 0) {
152
+ payload.data = dataUpdates;
153
+ if (channel.data) Object.assign(channel.data, dataUpdates);
154
+ }
155
+
156
+ if (capabilitiesArray !== null) {
157
+ payload.capabilities = capabilitiesArray;
158
+ if (channel.data) {
159
+ channel.data.member_capabilities = capabilitiesArray;
160
+ }
161
+ }
162
+
163
+ // Use _update instead of update to safely construct root-level payloads
164
+ await (channel as any)._update(payload);
165
+ }
166
+
167
+ if (isTopicsChanged) {
168
+ if (topicsEnabled) {
169
+ await channel.enableTopics();
170
+ } else {
171
+ await channel.disableTopics();
172
+ }
173
+ }
174
+
175
+ if (onClose) onClose();
176
+ } catch (err: any) {
177
+ setError(err?.message || 'Failed to update settings');
178
+ } finally {
179
+ setIsSaving(false);
180
+ }
181
+ }, [
182
+ channel,
183
+ isSlowModeChanged,
184
+ slowMode,
185
+ isKeywordsChanged,
186
+ keywords,
187
+ isCapabilitiesChanged,
188
+ capabilities,
189
+ isTopicsChanged,
190
+ topicsEnabled,
191
+ onClose,
192
+ ]);
193
+
194
+ return {
195
+ slowMode,
196
+ setSlowMode,
197
+ topicsEnabled,
198
+ setTopicsEnabled,
199
+ capabilities,
200
+ toggleCapability,
201
+ keywords,
202
+ newKeyword,
203
+ setNewKeyword,
204
+ handleAddNewKeyword,
205
+ handleRemoveKeyword,
206
+ isSaving,
207
+ error,
208
+ isDirty,
209
+ isOwner,
210
+ handleSave,
211
+ };
212
+ };