@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.
- package/dist/index.cjs +15295 -4209
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +701 -195
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +862 -94
- package/dist/index.d.ts +862 -94
- package/dist/index.mjs +15246 -4186
- package/dist/index.mjs.map +1 -1
- package/package.json +9 -4
- package/src/channelTypeUtils.ts +1 -1
- package/src/components/Avatar.tsx +2 -1
- package/src/components/Channel.tsx +6 -2
- package/src/components/ChannelActions.tsx +61 -2
- package/src/components/ChannelHeader.tsx +19 -5
- package/src/components/ChannelInfo/AddMemberModal.tsx +5 -1
- package/src/components/ChannelInfo/ChannelInfo.tsx +330 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +4 -1
- package/src/components/ChannelInfo/MediaGridItem.tsx +12 -2
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -3
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +27 -126
- package/src/components/ChannelInfo/States.tsx +1 -1
- package/src/components/ChannelInfo/index.ts +3 -0
- package/src/components/ChannelInfo/useChannelInfoTabs.tsx +386 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +177 -290
- package/src/components/CreateChannelModal.tsx +166 -88
- package/src/components/Dropdown.tsx +1 -16
- package/src/components/EditPreview.tsx +1 -0
- package/src/components/ErmisCallProvider.tsx +72 -17
- package/src/components/ErmisCallUI.tsx +43 -20
- package/src/components/FlatTopicGroupItem.tsx +232 -0
- package/src/components/ForwardMessageModal.tsx +31 -77
- package/src/components/MediaLightbox.tsx +62 -40
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +4 -1
- package/src/components/MessageInput.tsx +137 -16
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +93 -26
- package/src/components/MessageQuickReactions.tsx +153 -26
- package/src/components/MessageReactions.tsx +2 -1
- package/src/components/MessageRenderers.tsx +111 -39
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +17 -5
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/TopicList.tsx +221 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +14 -5
- package/src/components/UserPicker.tsx +87 -10
- package/src/components/VirtualMessageList.tsx +106 -20
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +18 -14
- package/src/context/ErmisCallContext.tsx +4 -0
- package/src/hooks/useChannelCapabilities.ts +7 -4
- package/src/hooks/useChannelData.ts +10 -3
- package/src/hooks/useChannelListUpdates.ts +72 -20
- package/src/hooks/useChannelMessages.ts +72 -10
- package/src/hooks/useChannelRowUpdates.ts +24 -5
- package/src/hooks/useChatUser.ts +31 -0
- package/src/hooks/useContactChannels.ts +45 -0
- package/src/hooks/useContactCount.ts +50 -0
- package/src/hooks/useDownloadHandler.ts +36 -0
- package/src/hooks/useDragAndDrop.ts +79 -0
- package/src/hooks/useForwardMessage.ts +112 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useMentions.ts +0 -1
- package/src/hooks/useMessageActions.ts +13 -10
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +197 -0
- package/src/index.ts +56 -6
- package/src/messageTypeUtils.ts +13 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +41 -4
- package/src/styles/_channel-list.css +97 -57
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +32 -0
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +286 -107
- package/src/styles/_message-input.css +131 -0
- package/src/styles/_message-list.css +33 -17
- package/src/styles/_message-quick-reactions.css +40 -9
- package/src/styles/_message-reactions.css +4 -0
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_tokens.css +17 -15
- package/src/styles/_typing-indicator.css +7 -1
- package/src/styles/index.css +1 -0
- package/src/types.ts +362 -14
- package/src/utils/avatarColors.ts +48 -0
- 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
|
+
};
|