@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
@@ -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
- export const MediaGridItem: React.FC<{
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
- onClick: (url: string) => void;
8
- }> = React.memo(({ item, onClick }) => {
9
- const src = item.thumb_url || item.url;
10
- const alreadyCached = isImagePreloaded(src);
11
- const [loaded, setLoaded] = useState(alreadyCached);
12
- const imgRef = React.useRef<HTMLImageElement>(null);
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
- // Trigger background preload (no-op if already cached)
15
- useMemo(() => { preloadImage(src); }, [src]);
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
- // Fallback checks for browser cache when JS preload didn't catch it
18
- React.useEffect(() => {
19
- if (!loaded && imgRef.current?.complete) {
20
- setLoaded(true);
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
- }, [loaded, src]);
77
+ await original.download(item.file_name);
78
+ }, [isImage, isVideo, item.file_name, manifest, original]);
23
79
 
24
- const isVideo = item.attachment_type === 'video';
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-item"
29
- onClick={() => onClick(item.url)}
30
- title={item.file_name}
31
- >
32
- {/* Shimmer placeholder while loading */}
33
- {!loaded && <div className="ermis-channel-info__media-shimmer" />}
34
-
35
- {isVideo ? (
36
- <div className="ermis-channel-info__media-video-thumb">
37
- {item.thumb_url ? (
38
- <img
39
- ref={imgRef}
40
- src={item.thumb_url}
41
- alt={item.file_name || 'video'}
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
- <img
62
- ref={imgRef}
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
- }, (prev, next) => prev.item.id === next.item.id);
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(({ row, onClick }: { row: AttachmentItem[], onClick: (url: string) => void }) => {
76
- return (
77
- <div className="ermis-channel-info__media-grid-row">
78
- {row.map(item => (
79
- <MediaGridItem key={item.id} item={item} onClick={onClick} />
80
- ))}
81
- {row.length < 3 && Array.from({ length: 3 - row.length }).map((_, i) => (
82
- <div key={`empty-${i}`} className="ermis-channel-info__media-item ermis-channel-info__media-item--empty" />
83
- ))}
84
- </div>
85
- );
86
- }, (prev, next) => {
87
- if (prev.row.length !== next.row.length) return false;
88
- return prev.row.every((item, i) => item.id === next.row[i].id);
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?.user_id === next.member?.user_id &&
78
- prev.member?.channel_role === next.member?.channel_role &&
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, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
1
+ import React, { useRef, useCallback, useMemo, useEffect } from 'react';
2
2
  import type { Channel } from '@ermis-network/ermis-chat-sdk';
3
- import { useChatClient } from '../../hooks/useChatClient';
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 type { AvatarProps, SearchResultMessage, MessageSearchPanelProps } from '../../types';
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 [query, setQuery] = useState('');
68
- const [results, setResults] = useState<SearchResultMessage[]>([]);
69
- const [loading, setLoading] = useState(false);
70
- const [hasMore, setHasMore] = useState(false);
71
- const [loadingMore, setLoadingMore] = useState(false);
72
-
73
- const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
74
- const scrollRef = useRef<HTMLDivElement>(null);
75
- const inputRef = useRef<HTMLInputElement>(null);
76
- const offsetRef = useRef(0);
77
- const queryRef = useRef('');
78
-
79
- // Reset all state when the channel changes (or panel closes)
80
- useEffect(() => {
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
- setQuery('');
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';