@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.
- package/README.md +144 -0
- package/dist/index.cjs +8320 -3427
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +1277 -291
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +1131 -99
- package/dist/index.d.ts +1131 -99
- package/dist/index.mjs +8168 -3319
- 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 -5
- package/src/components/ChannelActions.tsx +67 -3
- package/src/components/ChannelHeader.tsx +27 -37
- package/src/components/ChannelInfo/AddMemberModal.tsx +12 -2
- package/src/components/ChannelInfo/ChannelInfo.tsx +410 -187
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +59 -297
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +30 -174
- package/src/components/ChannelInfo/EditChannelModal.tsx +6 -3
- package/src/components/ChannelInfo/MediaGridItem.tsx +215 -68
- 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 +427 -0
- package/src/components/ChannelInfo/useChannelSettings.ts +212 -0
- package/src/components/ChannelInfo/useMessageSearch.tsx +141 -0
- package/src/components/ChannelList.tsx +247 -301
- package/src/components/CreateChannelModal.tsx +290 -93
- 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/FilesPreview.tsx +8 -12
- package/src/components/FlatTopicGroupItem.tsx +243 -0
- package/src/components/ForwardMessageModal.tsx +43 -81
- package/src/components/MediaLightbox.tsx +454 -292
- package/src/components/MentionSuggestions.tsx +47 -35
- package/src/components/MessageActionsBox.tsx +6 -1
- package/src/components/MessageInput.tsx +165 -17
- package/src/components/MessageInputDefaults.tsx +127 -1
- package/src/components/MessageItem.tsx +155 -43
- package/src/components/MessageQuickReactions.tsx +153 -23
- package/src/components/MessageReactions.tsx +49 -3
- package/src/components/MessageRenderers.tsx +1114 -445
- package/src/components/Panel.tsx +1 -14
- package/src/components/PinnedMessages.tsx +55 -15
- package/src/components/PreviewOverlay.tsx +24 -0
- package/src/components/QuotedMessagePreview.tsx +99 -8
- package/src/components/ReadReceipts.tsx +2 -1
- package/src/components/RecoveryPin/RecoveryPin.tsx +279 -0
- package/src/components/RecoveryPin/index.ts +19 -0
- package/src/components/TopicList.tsx +236 -0
- package/src/components/TopicModal.tsx +4 -1
- package/src/components/TypingIndicator.tsx +17 -8
- package/src/components/UserPicker.tsx +94 -16
- package/src/components/VirtualMessageList.tsx +419 -113
- package/src/context/ChatComponentsContext.tsx +14 -0
- package/src/context/ChatProvider.tsx +44 -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 +94 -21
- package/src/hooks/useChannelMessages.ts +391 -42
- package/src/hooks/useChannelRowUpdates.ts +36 -5
- package/src/hooks/useChatUser.ts +39 -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/useE2eeAttachmentRenderer.ts +204 -0
- package/src/hooks/useE2eeFileUpload.ts +38 -0
- package/src/hooks/useFileUpload.ts +25 -5
- package/src/hooks/useForwardMessage.ts +309 -0
- package/src/hooks/useInviteChannels.ts +88 -0
- package/src/hooks/useInviteCount.ts +104 -0
- package/src/hooks/useLoadMessages.ts +16 -4
- package/src/hooks/useMentions.ts +60 -7
- package/src/hooks/useMessageActions.ts +19 -10
- package/src/hooks/useMessageSend.ts +64 -12
- package/src/hooks/usePendingE2eeSends.ts +29 -0
- package/src/hooks/usePendingState.ts +21 -4
- package/src/hooks/usePreviewState.ts +69 -0
- package/src/hooks/useRecoveryPin.ts +287 -0
- package/src/hooks/useScrollToMessage.ts +29 -4
- package/src/hooks/useStickerPicker.ts +62 -0
- package/src/hooks/useTopicGroupUpdates.ts +235 -0
- package/src/index.ts +79 -6
- package/src/messageTypeUtils.ts +27 -1
- package/src/styles/_base.css +0 -1
- package/src/styles/_call-ui.css +59 -2
- package/src/styles/_channel-info.css +50 -4
- package/src/styles/_channel-list.css +131 -68
- package/src/styles/_create-channel-modal.css +10 -0
- package/src/styles/_forward-modal.css +16 -1
- package/src/styles/_media-lightbox.css +67 -2
- package/src/styles/_mentions.css +1 -1
- package/src/styles/_message-actions.css +3 -4
- package/src/styles/_message-bubble.css +631 -112
- package/src/styles/_message-input.css +139 -0
- package/src/styles/_message-list.css +91 -18
- package/src/styles/_message-quick-reactions.css +105 -32
- package/src/styles/_message-reactions.css +22 -32
- package/src/styles/_modal.css +2 -1
- package/src/styles/_preview-overlay.css +38 -0
- package/src/styles/_recovery-pin.css +97 -0
- package/src/styles/_tokens.css +22 -20
- package/src/styles/_typing-indicator.css +26 -10
- package/src/styles/index.css +2 -0
- package/src/types.ts +477 -15
- package/src/utils/avatarColors.ts +48 -0
- package/src/utils.ts +219 -16
|
@@ -1,90 +1,237 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
1
|
+
import React, { useCallback, useEffect, useRef, useState, useMemo } from 'react';
|
|
2
2
|
import { preloadImage, isImagePreloaded } from '../../utils';
|
|
3
|
-
import type { AttachmentItem } from '../../types';
|
|
3
|
+
import type { AttachmentItem, MediaLightboxItem } from '../../types';
|
|
4
|
+
import { MediaLightbox } from '../MediaLightbox';
|
|
5
|
+
import { useChatClient } from '../../hooks/useChatClient';
|
|
6
|
+
import { E2EE_PREVIEW_MAX_CONCURRENT, useE2eeAttachmentRenderer } from '../../hooks/useE2eeAttachmentRenderer';
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
let activeChannelInfoPreviewLoads = 0;
|
|
9
|
+
const queuedChannelInfoPreviewLoads: Array<() => void> = [];
|
|
10
|
+
|
|
11
|
+
function scheduleChannelInfoPreviewLoad(load: () => Promise<unknown>): void {
|
|
12
|
+
const run = () => {
|
|
13
|
+
activeChannelInfoPreviewLoads += 1;
|
|
14
|
+
void load().finally(() => {
|
|
15
|
+
activeChannelInfoPreviewLoads = Math.max(0, activeChannelInfoPreviewLoads - 1);
|
|
16
|
+
const next = queuedChannelInfoPreviewLoads.shift();
|
|
17
|
+
if (next) next();
|
|
18
|
+
});
|
|
19
|
+
};
|
|
20
|
+
if (activeChannelInfoPreviewLoads < E2EE_PREVIEW_MAX_CONCURRENT) run();
|
|
21
|
+
else queuedChannelInfoPreviewLoads.push(run);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const E2eeMediaGridItem: React.FC<{
|
|
6
25
|
item: AttachmentItem;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
const
|
|
10
|
-
const
|
|
11
|
-
const
|
|
12
|
-
const
|
|
26
|
+
}> = ({ item }) => {
|
|
27
|
+
const { activeChannel } = useChatClient();
|
|
28
|
+
const previewRef = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
const manifest = item.e2ee_manifest;
|
|
30
|
+
const preview = useE2eeAttachmentRenderer(activeChannel, manifest, 'preview');
|
|
31
|
+
const original = useE2eeAttachmentRenderer(activeChannel, manifest, 'original');
|
|
32
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
33
|
+
const hasPreview = Boolean(manifest?.assets.some((asset) => asset.kind === 'preview'));
|
|
34
|
+
const isVideo = item.attachment_type === 'video';
|
|
35
|
+
const isImage = item.attachment_type === 'image';
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
if (!manifest || !hasPreview || preview.url || preview.loading || preview.error) return;
|
|
39
|
+
const element = previewRef.current;
|
|
40
|
+
if (!element || typeof IntersectionObserver === 'undefined') {
|
|
41
|
+
scheduleChannelInfoPreviewLoad(preview.load);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
let scheduled = false;
|
|
45
|
+
const observer = new IntersectionObserver(
|
|
46
|
+
(entries) => {
|
|
47
|
+
if (scheduled) return;
|
|
48
|
+
if (entries.some((entry) => entry.isIntersecting)) {
|
|
49
|
+
scheduled = true;
|
|
50
|
+
observer.disconnect();
|
|
51
|
+
scheduleChannelInfoPreviewLoad(preview.load);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
{ rootMargin: '120px' },
|
|
55
|
+
);
|
|
56
|
+
observer.observe(element);
|
|
57
|
+
return () => observer.disconnect();
|
|
58
|
+
}, [hasPreview, manifest, preview.error, preview.load, preview.loading, preview.url]);
|
|
13
59
|
|
|
14
|
-
|
|
15
|
-
|
|
60
|
+
const progressLabel = original.progress?.percentage
|
|
61
|
+
? `${original.progress.phase} ${original.progress.percentage}%`
|
|
62
|
+
: original.loading
|
|
63
|
+
? original.progress?.phase || 'Loading'
|
|
64
|
+
: undefined;
|
|
16
65
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (
|
|
20
|
-
|
|
66
|
+
const openOriginal = useCallback(async () => {
|
|
67
|
+
if (!manifest) return;
|
|
68
|
+
if (isImage || isVideo) {
|
|
69
|
+
setLightboxOpen(true);
|
|
70
|
+
if (isVideo && !original.streamUrl && !original.streamLoading) {
|
|
71
|
+
void original.loadStream().then((streamUrl) => {
|
|
72
|
+
if (!streamUrl && !original.url && !original.loading) void original.load();
|
|
73
|
+
});
|
|
74
|
+
} else if (!original.url && !original.loading && !original.streamUrl) void original.load();
|
|
75
|
+
return;
|
|
21
76
|
}
|
|
22
|
-
|
|
77
|
+
await original.download(item.file_name);
|
|
78
|
+
}, [isImage, isVideo, item.file_name, manifest, original]);
|
|
23
79
|
|
|
24
|
-
const
|
|
80
|
+
const lightboxItems = useMemo<MediaLightboxItem[]>(
|
|
81
|
+
() => [
|
|
82
|
+
{
|
|
83
|
+
type: isVideo ? 'video' : 'image',
|
|
84
|
+
src: original.streamUrl || original.url,
|
|
85
|
+
posterSrc: preview.url,
|
|
86
|
+
alt: item.file_name,
|
|
87
|
+
loading: original.loading || (lightboxOpen && !original.streamUrl && !original.url && !original.error),
|
|
88
|
+
progressLabel,
|
|
89
|
+
download: async () => {
|
|
90
|
+
await original.download(item.file_name);
|
|
91
|
+
},
|
|
92
|
+
onPlaybackError: async () => {
|
|
93
|
+
await original.disposeStream();
|
|
94
|
+
if (!original.url && !original.loading) await original.load();
|
|
95
|
+
},
|
|
96
|
+
onDispose: original.disposeStream,
|
|
97
|
+
},
|
|
98
|
+
],
|
|
99
|
+
[isVideo, item.file_name, lightboxOpen, original, preview.url, progressLabel],
|
|
100
|
+
);
|
|
25
101
|
|
|
26
102
|
return (
|
|
27
|
-
<div
|
|
28
|
-
className="ermis-channel-info__media-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
loading="lazy"
|
|
43
|
-
onLoad={() => setLoaded(true)}
|
|
44
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
45
|
-
/>
|
|
46
|
-
) : (
|
|
47
|
-
<video
|
|
48
|
-
src={item.url}
|
|
49
|
-
preload="metadata"
|
|
50
|
-
onLoadedData={() => setLoaded(true)}
|
|
51
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
52
|
-
/>
|
|
103
|
+
<div className="ermis-channel-info__media-item" onClick={openOriginal} ref={previewRef} title={item.file_name}>
|
|
104
|
+
{!preview.url && <div className="ermis-channel-info__media-shimmer" />}
|
|
105
|
+
{preview.url ? (
|
|
106
|
+
<div className={isVideo ? 'ermis-channel-info__media-video-thumb' : undefined}>
|
|
107
|
+
<img src={preview.url} alt={item.file_name || 'encrypted media'} loading="lazy" decoding="async" />
|
|
108
|
+
{(isVideo || original.loading) && (
|
|
109
|
+
<div className="ermis-channel-info__media-play-icon">
|
|
110
|
+
{original.loading ? (
|
|
111
|
+
<span className="ermis-channel-info__media-spinner" />
|
|
112
|
+
) : (
|
|
113
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
114
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
115
|
+
</svg>
|
|
116
|
+
)}
|
|
117
|
+
</div>
|
|
53
118
|
)}
|
|
119
|
+
</div>
|
|
120
|
+
) : (
|
|
121
|
+
<div className="ermis-channel-info__media-video-thumb">
|
|
54
122
|
<div className="ermis-channel-info__media-play-icon">
|
|
55
123
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
56
124
|
<polygon points="5 3 19 12 5 21 5 3" />
|
|
57
125
|
</svg>
|
|
58
126
|
</div>
|
|
59
127
|
</div>
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
src={src}
|
|
64
|
-
alt={item.file_name || 'media'}
|
|
65
|
-
loading="lazy"
|
|
66
|
-
onLoad={() => setLoaded(true)}
|
|
67
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
68
|
-
/>
|
|
128
|
+
)}
|
|
129
|
+
{lightboxOpen && (
|
|
130
|
+
<MediaLightbox items={lightboxItems} isOpen={lightboxOpen} onClose={() => setLightboxOpen(false)} />
|
|
69
131
|
)}
|
|
70
132
|
</div>
|
|
71
133
|
);
|
|
72
|
-
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
export const MediaGridItem: React.FC<{
|
|
137
|
+
item: AttachmentItem;
|
|
138
|
+
onClick: (url: string) => void;
|
|
139
|
+
}> = React.memo(
|
|
140
|
+
({ item, onClick }) => {
|
|
141
|
+
if (item.e2ee_manifest || item.e2ee_manifest_missing) return <E2eeMediaGridItem item={item} />;
|
|
142
|
+
const src = item.thumb_url || item.url;
|
|
143
|
+
const alreadyCached = isImagePreloaded(src);
|
|
144
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
145
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
146
|
+
|
|
147
|
+
// Trigger background preload (no-op if already cached)
|
|
148
|
+
useMemo(() => {
|
|
149
|
+
preloadImage(src);
|
|
150
|
+
}, [src]);
|
|
151
|
+
|
|
152
|
+
// Fallback checks for browser cache when JS preload didn't catch it
|
|
153
|
+
React.useEffect(() => {
|
|
154
|
+
if (!loaded && imgRef.current?.complete) {
|
|
155
|
+
setLoaded(true);
|
|
156
|
+
}
|
|
157
|
+
}, [loaded, src]);
|
|
158
|
+
|
|
159
|
+
const isVideo = item.attachment_type === 'video';
|
|
160
|
+
|
|
161
|
+
return (
|
|
162
|
+
<div className="ermis-channel-info__media-item" onClick={() => onClick(item.url)} title={item.file_name}>
|
|
163
|
+
{/* Shimmer placeholder while loading */}
|
|
164
|
+
{!loaded && <div className="ermis-channel-info__media-shimmer" />}
|
|
165
|
+
|
|
166
|
+
{isVideo ? (
|
|
167
|
+
<div className="ermis-channel-info__media-video-thumb">
|
|
168
|
+
{item.thumb_url ? (
|
|
169
|
+
<img
|
|
170
|
+
ref={imgRef}
|
|
171
|
+
src={item.thumb_url}
|
|
172
|
+
alt={item.file_name || 'video'}
|
|
173
|
+
loading="lazy"
|
|
174
|
+
decoding="async"
|
|
175
|
+
onLoad={() => setLoaded(true)}
|
|
176
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
177
|
+
/>
|
|
178
|
+
) : (
|
|
179
|
+
<video
|
|
180
|
+
src={item.url}
|
|
181
|
+
preload="metadata"
|
|
182
|
+
onLoadedData={() => setLoaded(true)}
|
|
183
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
184
|
+
/>
|
|
185
|
+
)}
|
|
186
|
+
<div className="ermis-channel-info__media-play-icon">
|
|
187
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
188
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
189
|
+
</svg>
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
192
|
+
) : (
|
|
193
|
+
<img
|
|
194
|
+
ref={imgRef}
|
|
195
|
+
src={src}
|
|
196
|
+
alt={item.file_name || 'media'}
|
|
197
|
+
loading="lazy"
|
|
198
|
+
decoding="async"
|
|
199
|
+
onLoad={() => setLoaded(true)}
|
|
200
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
201
|
+
/>
|
|
202
|
+
)}
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
},
|
|
206
|
+
(prev, next) => prev.item.id === next.item.id,
|
|
207
|
+
);
|
|
73
208
|
(MediaGridItem as any).displayName = 'MediaGridItem';
|
|
74
209
|
|
|
75
|
-
export const MediaRow = React.memo(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
}
|
|
210
|
+
export const MediaRow = React.memo(
|
|
211
|
+
({
|
|
212
|
+
row,
|
|
213
|
+
onClick,
|
|
214
|
+
MediaItemComponent = MediaGridItem,
|
|
215
|
+
}: {
|
|
216
|
+
row: AttachmentItem[];
|
|
217
|
+
onClick: (url: string) => void;
|
|
218
|
+
MediaItemComponent?: React.ComponentType<{ item: AttachmentItem; onClick: (url: string) => void }>;
|
|
219
|
+
}) => {
|
|
220
|
+
return (
|
|
221
|
+
<div className="ermis-channel-info__media-grid-row">
|
|
222
|
+
{row.map((item) => (
|
|
223
|
+
<MediaItemComponent key={item.id} item={item} onClick={onClick} />
|
|
224
|
+
))}
|
|
225
|
+
{row.length < 3 &&
|
|
226
|
+
Array.from({ length: 3 - row.length }).map((_, i) => (
|
|
227
|
+
<div key={`empty-${i}`} className="ermis-channel-info__media-item ermis-channel-info__media-item--empty" />
|
|
228
|
+
))}
|
|
229
|
+
</div>
|
|
230
|
+
);
|
|
231
|
+
},
|
|
232
|
+
(prev, next) => {
|
|
233
|
+
if (prev.row.length !== next.row.length) return false;
|
|
234
|
+
return prev.row.every((item, i) => item.id === next.row[i].id);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
90
237
|
(MediaRow as any).displayName = 'MediaRow';
|
|
@@ -74,9 +74,8 @@ export const MemberListItem = React.memo(({
|
|
|
74
74
|
</div>
|
|
75
75
|
);
|
|
76
76
|
}, (prev, next) => {
|
|
77
|
-
return prev.member
|
|
78
|
-
prev.
|
|
79
|
-
prev.member?.banned === next.member?.banned &&
|
|
77
|
+
return prev.member === next.member &&
|
|
78
|
+
prev.AvatarComponent === next.AvatarComponent &&
|
|
80
79
|
prev.canRemove === next.canRemove &&
|
|
81
80
|
prev.canBan === next.canBan &&
|
|
82
81
|
prev.canUnban === next.canUnban &&
|
|
@@ -1,17 +1,19 @@
|
|
|
1
|
-
import React, {
|
|
1
|
+
import React, { useRef, useCallback, useMemo, useEffect } from 'react';
|
|
2
2
|
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
-
import {
|
|
4
|
-
import { replaceMentionsForPreview, buildUserMap, formatRelativeDate } from '../../utils';
|
|
3
|
+
import { replaceMentionsForPreview, formatRelativeDate } from '../../utils';
|
|
5
4
|
import { Avatar } from '../Avatar';
|
|
6
|
-
import { Panel } from '../Panel';
|
|
7
|
-
import
|
|
5
|
+
import { Panel as DefaultPanel } from '../Panel';
|
|
6
|
+
import { useChatComponents } from '../../context/ChatComponentsContext';
|
|
7
|
+
import { useChatClient } from '../../hooks/useChatClient';
|
|
8
|
+
import type { MessageSearchPanelProps } from '../../types';
|
|
9
|
+
import { useMessageSearch } from './useMessageSearch';
|
|
10
|
+
import { removeAccents } from '../../utils';
|
|
8
11
|
|
|
9
12
|
/* ----------------------------------------------------------
|
|
10
13
|
Highlight utility (Accent-insensitive)
|
|
11
14
|
---------------------------------------------------------- */
|
|
12
|
-
const removeAccents = (str: string) => str.normalize('NFD').replace(/[\u0300-\u036f]/g, '');
|
|
13
15
|
|
|
14
|
-
const HighlightedText: React.FC<{ text: string; term: string }> = React.memo(({ text, term }) => {
|
|
16
|
+
export const HighlightedText: React.FC<{ text: string; term: string }> = React.memo(({ text, term }) => {
|
|
15
17
|
if (!term.trim()) return <>{text}</>;
|
|
16
18
|
|
|
17
19
|
const cleanTerm = removeAccents(term).toLowerCase();
|
|
@@ -58,136 +60,40 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
|
|
|
58
60
|
AvatarComponent = Avatar,
|
|
59
61
|
placeholder = 'Search messages...',
|
|
60
62
|
title = 'Search Messages',
|
|
61
|
-
emptyText = 'No messages found',
|
|
63
|
+
emptyText = 'No messages found.',
|
|
62
64
|
loadingText = 'Searching...',
|
|
63
65
|
debounceMs = 500,
|
|
64
66
|
}) => {
|
|
65
67
|
const { setJumpToMessageId } = useChatClient();
|
|
66
|
-
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
const
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
setQuery('');
|
|
82
|
-
setResults([]);
|
|
83
|
-
setLoading(false);
|
|
84
|
-
setHasMore(false);
|
|
85
|
-
setLoadingMore(false);
|
|
86
|
-
offsetRef.current = 0;
|
|
87
|
-
queryRef.current = '';
|
|
88
|
-
}, [channel?.cid, isOpen]);
|
|
68
|
+
const { PanelComponent } = useChatComponents();
|
|
69
|
+
const Panel = PanelComponent || DefaultPanel;
|
|
70
|
+
|
|
71
|
+
const {
|
|
72
|
+
query,
|
|
73
|
+
setQuery,
|
|
74
|
+
results,
|
|
75
|
+
loading,
|
|
76
|
+
hasMore,
|
|
77
|
+
loadingMore,
|
|
78
|
+
handleInputChange,
|
|
79
|
+
handleScroll,
|
|
80
|
+
resetSearch,
|
|
81
|
+
userMaps,
|
|
82
|
+
} = useMessageSearch({ channel, isOpen, debounceMs });
|
|
89
83
|
|
|
90
84
|
// Auto-focus the input when panel opens
|
|
85
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
91
86
|
useEffect(() => {
|
|
92
87
|
if (isOpen) {
|
|
93
88
|
setTimeout(() => inputRef.current?.focus(), 300);
|
|
94
89
|
}
|
|
95
90
|
}, [isOpen]);
|
|
96
91
|
|
|
97
|
-
// Debounced search
|
|
98
|
-
const handleInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
99
|
-
const value = e.target.value;
|
|
100
|
-
setQuery(value);
|
|
101
|
-
|
|
102
|
-
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
103
|
-
|
|
104
|
-
if (!value.trim()) {
|
|
105
|
-
setResults([]);
|
|
106
|
-
setLoading(false);
|
|
107
|
-
setHasMore(false);
|
|
108
|
-
offsetRef.current = 0;
|
|
109
|
-
queryRef.current = '';
|
|
110
|
-
return;
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
setLoading(true);
|
|
114
|
-
|
|
115
|
-
debounceRef.current = setTimeout(async () => {
|
|
116
|
-
queryRef.current = value;
|
|
117
|
-
offsetRef.current = 0;
|
|
118
|
-
|
|
119
|
-
try {
|
|
120
|
-
const response = await channel.searchMessage(value, 0);
|
|
121
|
-
// Only apply if this is still the latest query
|
|
122
|
-
if (queryRef.current !== value) return;
|
|
123
|
-
|
|
124
|
-
if (!response) {
|
|
125
|
-
setResults([]);
|
|
126
|
-
setHasMore(false);
|
|
127
|
-
} else {
|
|
128
|
-
setResults(response.messages || []);
|
|
129
|
-
setHasMore((response.messages?.length || 0) >= 25);
|
|
130
|
-
}
|
|
131
|
-
} catch (err) {
|
|
132
|
-
console.error('Search failed:', err);
|
|
133
|
-
setResults([]);
|
|
134
|
-
setHasMore(false);
|
|
135
|
-
} finally {
|
|
136
|
-
setLoading(false);
|
|
137
|
-
}
|
|
138
|
-
}, debounceMs);
|
|
139
|
-
}, [channel, debounceMs]);
|
|
140
|
-
|
|
141
|
-
// Infinite scroll: load more results
|
|
142
|
-
const handleLoadMore = useCallback(async () => {
|
|
143
|
-
if (loadingMore || !hasMore || !queryRef.current) return;
|
|
144
|
-
|
|
145
|
-
setLoadingMore(true);
|
|
146
|
-
const nextOffset = offsetRef.current + 25; // offset skips records, limit is 25
|
|
147
|
-
|
|
148
|
-
try {
|
|
149
|
-
const response = await channel.searchMessage(queryRef.current, nextOffset);
|
|
150
|
-
|
|
151
|
-
if (!response || !response.messages?.length) {
|
|
152
|
-
setHasMore(false);
|
|
153
|
-
} else {
|
|
154
|
-
offsetRef.current = nextOffset;
|
|
155
|
-
setResults((prev) => [...prev, ...response.messages]);
|
|
156
|
-
setHasMore(response.messages.length >= 25);
|
|
157
|
-
}
|
|
158
|
-
} catch (err) {
|
|
159
|
-
console.error('Load more search results failed:', err);
|
|
160
|
-
} finally {
|
|
161
|
-
setLoadingMore(false);
|
|
162
|
-
}
|
|
163
|
-
}, [channel, hasMore, loadingMore]);
|
|
164
|
-
|
|
165
|
-
// Scroll handler for infinite scroll
|
|
166
|
-
const handleScroll = useCallback(() => {
|
|
167
|
-
const el = scrollRef.current;
|
|
168
|
-
if (!el) return;
|
|
169
|
-
|
|
170
|
-
const threshold = 100;
|
|
171
|
-
if (el.scrollTop + el.clientHeight >= el.scrollHeight - threshold) {
|
|
172
|
-
handleLoadMore();
|
|
173
|
-
}
|
|
174
|
-
}, [handleLoadMore]);
|
|
175
|
-
|
|
176
92
|
// Click a result -> jump to that message
|
|
177
93
|
const handleResultClick = useCallback((messageId: string) => {
|
|
178
94
|
setJumpToMessageId(messageId);
|
|
179
95
|
}, [setJumpToMessageId]);
|
|
180
96
|
|
|
181
|
-
// Derived userMap for resolving mentions, with a lowercase variant for fast lookup
|
|
182
|
-
const userMaps = useMemo(() => {
|
|
183
|
-
const original = buildUserMap(channel.state);
|
|
184
|
-
const lower: typeof original = {};
|
|
185
|
-
for (const [id, name] of Object.entries(original)) {
|
|
186
|
-
lower[id.toLowerCase()] = name;
|
|
187
|
-
}
|
|
188
|
-
return { original, lower };
|
|
189
|
-
}, [channel.state]);
|
|
190
|
-
|
|
191
97
|
return (
|
|
192
98
|
<Panel isOpen={isOpen} onClose={onClose} title={title} className="ermis-search-panel">
|
|
193
99
|
{/* Search Input now inside body */}
|
|
@@ -209,11 +115,7 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
|
|
|
209
115
|
<button
|
|
210
116
|
className="ermis-search-panel__input-clear"
|
|
211
117
|
onClick={() => {
|
|
212
|
-
|
|
213
|
-
setResults([]);
|
|
214
|
-
setHasMore(false);
|
|
215
|
-
offsetRef.current = 0;
|
|
216
|
-
queryRef.current = '';
|
|
118
|
+
resetSearch();
|
|
217
119
|
inputRef.current?.focus();
|
|
218
120
|
}}
|
|
219
121
|
aria-label="Clear"
|
|
@@ -228,7 +130,6 @@ export const MessageSearchPanel: React.FC<MessageSearchPanelProps> = React.memo(
|
|
|
228
130
|
</div>
|
|
229
131
|
|
|
230
132
|
<div
|
|
231
|
-
ref={scrollRef}
|
|
232
133
|
className="ermis-search-panel__results"
|
|
233
134
|
onScroll={handleScroll}
|
|
234
135
|
>
|
|
@@ -28,7 +28,7 @@ export const TabEmptyState: React.FC<{ label: string }> = React.memo(({ label })
|
|
|
28
28
|
));
|
|
29
29
|
(TabEmptyState as any).displayName = 'TabEmptyState';
|
|
30
30
|
|
|
31
|
-
export const TabLoadingState: React.FC = React.memo(() => (
|
|
31
|
+
export const TabLoadingState: React.FC<{ tab?: string }> = React.memo(() => (
|
|
32
32
|
<div className="ermis-channel-info__media-loading">
|
|
33
33
|
<div className="ermis-channel-info__media-spinner" />
|
|
34
34
|
</div>
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
export * from './ChannelInfo';
|
|
2
2
|
export * from './ChannelInfoTabs';
|
|
3
|
+
export * from './useChannelInfoTabs';
|
|
3
4
|
export * from './EditChannelModal';
|
|
4
5
|
export * from './MessageSearchPanel';
|
|
6
|
+
export * from './useMessageSearch';
|
|
5
7
|
export * from './MediaGridItem';
|
|
6
8
|
export * from './LinkListItem';
|
|
7
9
|
export * from './FileListItem';
|
|
8
10
|
export * from './MemberListItem';
|
|
9
11
|
export * from './States';
|
|
10
12
|
export * from './ChannelSettingsPanel';
|
|
13
|
+
export * from './useChannelSettings';
|