@ermis-network/ermis-chat-react 1.0.9 → 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 +15288 -4203
  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 +15238 -4179
  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 +126 -7
  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
@@ -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
+ };
@@ -0,0 +1,141 @@
1
+ import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
2
+ import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
+ import { buildUserMap } from '../../utils';
4
+ import type { SearchResultMessage } from '../../types';
5
+
6
+ export type UseMessageSearchProps = {
7
+ channel: Channel;
8
+ isOpen: boolean;
9
+ debounceMs?: number;
10
+ };
11
+
12
+ export const useMessageSearch = ({ channel, isOpen, debounceMs = 500 }: UseMessageSearchProps) => {
13
+ const [query, setQuery] = useState('');
14
+ const [results, setResults] = useState<SearchResultMessage[]>([]);
15
+ const [loading, setLoading] = useState(false);
16
+ const [hasMore, setHasMore] = useState(false);
17
+ const [loadingMore, setLoadingMore] = useState(false);
18
+
19
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
20
+ const offsetRef = useRef(0);
21
+ const queryRef = useRef('');
22
+
23
+ // Reset all state when the channel changes (or panel closes)
24
+ useEffect(() => {
25
+ setQuery('');
26
+ setResults([]);
27
+ setLoading(false);
28
+ setHasMore(false);
29
+ setLoadingMore(false);
30
+ offsetRef.current = 0;
31
+ queryRef.current = '';
32
+ }, [channel?.cid, isOpen]);
33
+
34
+ // Debounced search
35
+ const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
36
+ const value = e.target.value;
37
+ setQuery(value);
38
+
39
+ if (debounceRef.current) clearTimeout(debounceRef.current);
40
+
41
+ if (!value.trim()) {
42
+ setResults([]);
43
+ setLoading(false);
44
+ setHasMore(false);
45
+ offsetRef.current = 0;
46
+ queryRef.current = '';
47
+ return;
48
+ }
49
+
50
+ setLoading(true);
51
+
52
+ debounceRef.current = setTimeout(async () => {
53
+ queryRef.current = value;
54
+ offsetRef.current = 0;
55
+
56
+ try {
57
+ const response = await channel.searchMessage(value, 0);
58
+ // Only apply if this is still the latest query
59
+ if (queryRef.current !== value) return;
60
+
61
+ if (!response) {
62
+ setResults([]);
63
+ setHasMore(false);
64
+ } else {
65
+ setResults(response.messages || []);
66
+ setHasMore((response.messages?.length || 0) >= 25);
67
+ }
68
+ } catch (err) {
69
+ console.error('Search failed:', err);
70
+ setResults([]);
71
+ setHasMore(false);
72
+ } finally {
73
+ setLoading(false);
74
+ }
75
+ }, debounceMs);
76
+ }, [channel, debounceMs]);
77
+
78
+ const resetSearch = useCallback(() => {
79
+ setQuery('');
80
+ setResults([]);
81
+ setHasMore(false);
82
+ offsetRef.current = 0;
83
+ queryRef.current = '';
84
+ }, []);
85
+
86
+ // Infinite scroll: load more results
87
+ const handleLoadMore = useCallback(async () => {
88
+ if (loadingMore || !hasMore || !queryRef.current) return;
89
+
90
+ setLoadingMore(true);
91
+ const nextOffset = offsetRef.current + 25; // offset skips records, limit is 25
92
+
93
+ try {
94
+ const response = await channel.searchMessage(queryRef.current, nextOffset);
95
+
96
+ if (!response || !response.messages?.length) {
97
+ setHasMore(false);
98
+ } else {
99
+ offsetRef.current = nextOffset;
100
+ setResults((prev) => [...prev, ...response.messages]);
101
+ setHasMore(response.messages.length >= 25);
102
+ }
103
+ } catch (err) {
104
+ console.error('Load more search results failed:', err);
105
+ } finally {
106
+ setLoadingMore(false);
107
+ }
108
+ }, [channel, hasMore, loadingMore]);
109
+
110
+ // Scroll handler for infinite scroll container
111
+ const handleScroll = useCallback((e: React.UIEvent<HTMLElement>) => {
112
+ const el = e.currentTarget;
113
+ const threshold = 100;
114
+ if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
115
+ handleLoadMore();
116
+ }
117
+ }, [handleLoadMore]);
118
+
119
+ // Derived userMap for resolving mentions, with a lowercase variant for fast lookup
120
+ const userMaps = useMemo(() => {
121
+ const original = buildUserMap(channel.state);
122
+ const lower: typeof original = {};
123
+ for (const [id, name] of Object.entries(original)) {
124
+ lower[id.toLowerCase()] = name;
125
+ }
126
+ return { original, lower };
127
+ }, [channel.state]);
128
+
129
+ return {
130
+ query,
131
+ setQuery,
132
+ results,
133
+ loading,
134
+ hasMore,
135
+ loadingMore,
136
+ handleInputChange,
137
+ handleScroll,
138
+ resetSearch,
139
+ userMaps,
140
+ };
141
+ };