@ermis-network/ermis-chat-react 1.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 +6593 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.css +3375 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +1138 -0
- package/dist/index.d.ts +1138 -0
- package/dist/index.mjs +6500 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +42 -0
- package/src/components/Avatar.tsx +102 -0
- package/src/components/Channel.tsx +77 -0
- package/src/components/ChannelHeader.tsx +85 -0
- package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
- package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
- package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
- package/src/components/ChannelInfo/FileListItem.tsx +49 -0
- package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
- package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
- package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
- package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
- package/src/components/ChannelInfo/States.tsx +36 -0
- package/src/components/ChannelInfo/index.ts +10 -0
- package/src/components/ChannelInfo/utils.tsx +49 -0
- package/src/components/ChannelList.tsx +395 -0
- package/src/components/Dropdown.tsx +120 -0
- package/src/components/EditPreview.tsx +102 -0
- package/src/components/FilesPreview.tsx +108 -0
- package/src/components/ForwardMessageModal.tsx +234 -0
- package/src/components/MentionSuggestions.tsx +59 -0
- package/src/components/MessageActionsBox.tsx +186 -0
- package/src/components/MessageInput.tsx +513 -0
- package/src/components/MessageInputDefaults.tsx +50 -0
- package/src/components/MessageItem.tsx +218 -0
- package/src/components/MessageQuickReactions.tsx +73 -0
- package/src/components/MessageReactions.tsx +59 -0
- package/src/components/MessageRenderers.tsx +565 -0
- package/src/components/Modal.tsx +58 -0
- package/src/components/Panel.tsx +64 -0
- package/src/components/PinnedMessages.tsx +165 -0
- package/src/components/QuotedMessagePreview.tsx +55 -0
- package/src/components/ReadReceipts.tsx +80 -0
- package/src/components/ReplyPreview.tsx +98 -0
- package/src/components/TypingIndicator.tsx +57 -0
- package/src/components/VirtualMessageList.tsx +425 -0
- package/src/context/ChatProvider.tsx +73 -0
- package/src/hooks/useBannedState.ts +48 -0
- package/src/hooks/useBlockedState.ts +55 -0
- package/src/hooks/useChannel.ts +18 -0
- package/src/hooks/useChannelCapabilities.ts +42 -0
- package/src/hooks/useChannelData.ts +55 -0
- package/src/hooks/useChannelListUpdates.ts +224 -0
- package/src/hooks/useChannelMessages.ts +159 -0
- package/src/hooks/useChannelRowUpdates.ts +78 -0
- package/src/hooks/useChatClient.ts +11 -0
- package/src/hooks/useEmojiPicker.ts +53 -0
- package/src/hooks/useFileUpload.ts +128 -0
- package/src/hooks/useLoadMessages.ts +178 -0
- package/src/hooks/useMentions.ts +287 -0
- package/src/hooks/useMessageActions.ts +87 -0
- package/src/hooks/useMessageSend.ts +164 -0
- package/src/hooks/usePendingState.ts +63 -0
- package/src/hooks/useScrollToMessage.ts +155 -0
- package/src/hooks/useTypingIndicator.ts +86 -0
- package/src/index.ts +129 -0
- package/src/styles/_add-member-modal.css +122 -0
- package/src/styles/_base.css +32 -0
- package/src/styles/_channel-info.css +941 -0
- package/src/styles/_channel-list.css +217 -0
- package/src/styles/_dropdown.css +69 -0
- package/src/styles/_forward-modal.css +191 -0
- package/src/styles/_mentions.css +102 -0
- package/src/styles/_message-actions.css +61 -0
- package/src/styles/_message-bubble.css +656 -0
- package/src/styles/_message-input.css +389 -0
- package/src/styles/_message-list.css +416 -0
- package/src/styles/_message-quick-reactions.css +62 -0
- package/src/styles/_message-reactions.css +67 -0
- package/src/styles/_modal.css +113 -0
- package/src/styles/_panel.css +69 -0
- package/src/styles/_pinned-messages.css +140 -0
- package/src/styles/_search-panel.css +219 -0
- package/src/styles/_tokens.css +92 -0
- package/src/styles/_typing-indicator.css +59 -0
- package/src/styles/index.css +24 -0
- package/src/types.ts +955 -0
- package/src/utils.ts +242 -0
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { preloadImage, isImagePreloaded } from '../utils';
|
|
3
|
+
import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import { parseSystemMessage, parseSignalMessage } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
6
|
+
import { buildUserMap } from '../utils';
|
|
7
|
+
import type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
|
|
8
|
+
|
|
9
|
+
export type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
|
|
10
|
+
|
|
11
|
+
/* ----------------------------------------------------------
|
|
12
|
+
Attachment type helpers
|
|
13
|
+
---------------------------------------------------------- */
|
|
14
|
+
function isImage(attachment: Attachment): boolean {
|
|
15
|
+
return !!(
|
|
16
|
+
attachment.type === 'image' ||
|
|
17
|
+
(!attachment.type && (attachment.mime_type?.startsWith('image/') || attachment.image_url))
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isVideo(attachment: Attachment): boolean {
|
|
22
|
+
return !!(attachment.type === 'video' || (!attachment.type && attachment.mime_type?.startsWith('video/')));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function isVoiceRecording(attachment: Attachment): boolean {
|
|
26
|
+
return attachment.type === 'voiceRecording';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLinkPreview(attachment: Attachment): boolean {
|
|
30
|
+
return attachment.type === 'linkPreview';
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/* ----------------------------------------------------------
|
|
34
|
+
Attachment renderers
|
|
35
|
+
---------------------------------------------------------- */
|
|
36
|
+
const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
37
|
+
const src = attachment.image_url || attachment.thumb_url || attachment.url;
|
|
38
|
+
const thumbSrc = attachment.thumb_url;
|
|
39
|
+
if (!src) return null;
|
|
40
|
+
|
|
41
|
+
const alreadyCached = isImagePreloaded(src);
|
|
42
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
43
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
44
|
+
|
|
45
|
+
// Trigger background preload (no-op if already cached)
|
|
46
|
+
useMemo(() => { preloadImage(src); }, [src]);
|
|
47
|
+
|
|
48
|
+
React.useEffect(() => {
|
|
49
|
+
if (!loaded && imgRef.current?.complete) {
|
|
50
|
+
setLoaded(true);
|
|
51
|
+
}
|
|
52
|
+
}, [loaded, src]);
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<div className="ermis-attachment-aspect-box" style={{ paddingBottom: '75%' }}>
|
|
56
|
+
{/* Blur placeholder: use thumb if available, otherwise shimmer */}
|
|
57
|
+
{!loaded && (
|
|
58
|
+
thumbSrc && thumbSrc !== src ? (
|
|
59
|
+
<img
|
|
60
|
+
className="ermis-attachment-blur-preview"
|
|
61
|
+
src={thumbSrc}
|
|
62
|
+
alt=""
|
|
63
|
+
aria-hidden
|
|
64
|
+
/>
|
|
65
|
+
) : (
|
|
66
|
+
<div className="ermis-attachment-shimmer" />
|
|
67
|
+
)
|
|
68
|
+
)}
|
|
69
|
+
<img
|
|
70
|
+
ref={imgRef}
|
|
71
|
+
className={`ermis-attachment ermis-attachment--image${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
72
|
+
src={src}
|
|
73
|
+
alt={attachment.file_name || attachment.title || 'image'}
|
|
74
|
+
loading="lazy"
|
|
75
|
+
onLoad={() => setLoaded(true)}
|
|
76
|
+
/>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}, (prev, next) => {
|
|
80
|
+
const prevSrc = prev.attachment.image_url || prev.attachment.thumb_url || prev.attachment.url;
|
|
81
|
+
const nextSrc = next.attachment.image_url || next.attachment.thumb_url || next.attachment.url;
|
|
82
|
+
return prevSrc === nextSrc;
|
|
83
|
+
});
|
|
84
|
+
(ImageAttachment as any).displayName = 'ImageAttachment';
|
|
85
|
+
|
|
86
|
+
const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
87
|
+
const src = attachment.asset_url || attachment.url;
|
|
88
|
+
const posterSrc = attachment.image_url || attachment.thumb_url;
|
|
89
|
+
const blurThumb = attachment.thumb_url;
|
|
90
|
+
if (!src) return null;
|
|
91
|
+
|
|
92
|
+
const alreadyCached = posterSrc ? isImagePreloaded(posterSrc) : true;
|
|
93
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
94
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
95
|
+
|
|
96
|
+
useMemo(() => {
|
|
97
|
+
if (posterSrc) preloadImage(posterSrc);
|
|
98
|
+
}, [posterSrc]);
|
|
99
|
+
|
|
100
|
+
React.useEffect(() => {
|
|
101
|
+
if (!loaded && imgRef.current?.complete) {
|
|
102
|
+
setLoaded(true);
|
|
103
|
+
}
|
|
104
|
+
}, [loaded, posterSrc]);
|
|
105
|
+
|
|
106
|
+
return (
|
|
107
|
+
<div className="ermis-attachment-aspect-box" style={{ paddingBottom: '75%' }}>
|
|
108
|
+
{!loaded && (
|
|
109
|
+
blurThumb && blurThumb !== posterSrc ? (
|
|
110
|
+
<img
|
|
111
|
+
className="ermis-attachment-blur-preview"
|
|
112
|
+
src={blurThumb}
|
|
113
|
+
alt=""
|
|
114
|
+
aria-hidden
|
|
115
|
+
/>
|
|
116
|
+
) : (
|
|
117
|
+
<div className="ermis-attachment-shimmer" />
|
|
118
|
+
)
|
|
119
|
+
)}
|
|
120
|
+
{posterSrc && !loaded && (
|
|
121
|
+
<img
|
|
122
|
+
ref={imgRef}
|
|
123
|
+
src={posterSrc}
|
|
124
|
+
style={{ display: 'none' }}
|
|
125
|
+
onLoad={() => setLoaded(true)}
|
|
126
|
+
alt="poster-loader"
|
|
127
|
+
/>
|
|
128
|
+
)}
|
|
129
|
+
<video
|
|
130
|
+
className={`ermis-attachment ermis-attachment--video${loaded || !posterSrc ? ' ermis-attachment--loaded' : ''}`}
|
|
131
|
+
src={src}
|
|
132
|
+
poster={posterSrc}
|
|
133
|
+
controls
|
|
134
|
+
preload="metadata"
|
|
135
|
+
onLoadedData={() => {
|
|
136
|
+
if (!posterSrc) setLoaded(true);
|
|
137
|
+
}}
|
|
138
|
+
/>
|
|
139
|
+
</div>
|
|
140
|
+
);
|
|
141
|
+
}, (prev, next) => {
|
|
142
|
+
return (prev.attachment.asset_url || prev.attachment.url) ===
|
|
143
|
+
(next.attachment.asset_url || next.attachment.url);
|
|
144
|
+
});
|
|
145
|
+
(VideoAttachment as any).displayName = 'VideoAttachment';
|
|
146
|
+
|
|
147
|
+
const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
148
|
+
const url = attachment.url || attachment.asset_url;
|
|
149
|
+
const name = attachment.file_name || attachment.title || 'File';
|
|
150
|
+
const size = attachment.file_size;
|
|
151
|
+
|
|
152
|
+
return (
|
|
153
|
+
<a
|
|
154
|
+
className="ermis-attachment ermis-attachment--file"
|
|
155
|
+
href={url}
|
|
156
|
+
download={name}
|
|
157
|
+
target="_blank"
|
|
158
|
+
rel="noopener noreferrer"
|
|
159
|
+
>
|
|
160
|
+
<span className="ermis-attachment__file-icon">⬇️</span>
|
|
161
|
+
<span className="ermis-attachment__file-info">
|
|
162
|
+
<span className="ermis-attachment__file-name">{name}</span>
|
|
163
|
+
{size && (
|
|
164
|
+
<span className="ermis-attachment__file-size">
|
|
165
|
+
{typeof size === 'number' ? `${(size / 1024).toFixed(1)} KB` : size}
|
|
166
|
+
</span>
|
|
167
|
+
)}
|
|
168
|
+
</span>
|
|
169
|
+
</a>
|
|
170
|
+
);
|
|
171
|
+
}, (prev, next) => {
|
|
172
|
+
return (prev.attachment.url || prev.attachment.asset_url) ===
|
|
173
|
+
(next.attachment.url || next.attachment.asset_url);
|
|
174
|
+
});
|
|
175
|
+
(FileAttachment as any).displayName = 'FileAttachment';
|
|
176
|
+
|
|
177
|
+
const VoiceRecordingAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
178
|
+
const src = attachment.asset_url || attachment.url;
|
|
179
|
+
if (!src) return null;
|
|
180
|
+
|
|
181
|
+
const durationSec = attachment.duration ?? 0;
|
|
182
|
+
const mins = Math.floor(durationSec / 60);
|
|
183
|
+
const secs = Math.round(durationSec % 60);
|
|
184
|
+
const durationLabel = `${mins}:${secs.toString().padStart(2, '0')}`;
|
|
185
|
+
|
|
186
|
+
return (
|
|
187
|
+
<div className="ermis-attachment ermis-attachment--voice">
|
|
188
|
+
<span className="ermis-attachment__voice-icon">🎙️</span>
|
|
189
|
+
<audio src={src} controls preload="metadata" className="ermis-attachment__voice-player" />
|
|
190
|
+
<span className="ermis-attachment__voice-duration">{durationLabel}</span>
|
|
191
|
+
</div>
|
|
192
|
+
);
|
|
193
|
+
}, (prev, next) => {
|
|
194
|
+
return (prev.attachment.asset_url || prev.attachment.url) ===
|
|
195
|
+
(next.attachment.asset_url || next.attachment.url);
|
|
196
|
+
});
|
|
197
|
+
(VoiceRecordingAttachment as any).displayName = 'VoiceRecordingAttachment';
|
|
198
|
+
|
|
199
|
+
const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
200
|
+
const url = attachment.link_url || attachment.og_scrape_url || attachment.title_link || attachment.url;
|
|
201
|
+
const title = attachment.title;
|
|
202
|
+
const description = attachment.text;
|
|
203
|
+
const image = attachment.image_url;
|
|
204
|
+
|
|
205
|
+
const alreadyCached = image ? isImagePreloaded(image) : false;
|
|
206
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
207
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
208
|
+
|
|
209
|
+
useMemo(() => {
|
|
210
|
+
if (image) preloadImage(image);
|
|
211
|
+
}, [image]);
|
|
212
|
+
|
|
213
|
+
React.useEffect(() => {
|
|
214
|
+
if (!loaded && imgRef.current?.complete) {
|
|
215
|
+
setLoaded(true);
|
|
216
|
+
}
|
|
217
|
+
}, [loaded, image]);
|
|
218
|
+
|
|
219
|
+
if (!title) return null;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<a
|
|
223
|
+
className="ermis-attachment ermis-attachment--link-preview"
|
|
224
|
+
href={url}
|
|
225
|
+
target="_blank"
|
|
226
|
+
rel="noopener noreferrer"
|
|
227
|
+
>
|
|
228
|
+
{image && (
|
|
229
|
+
<div style={{ position: 'relative', width: '100%', minHeight: '120px', backgroundColor: 'var(--ermis-bg-hover, #2a2a4a)', overflow: 'hidden' }}>
|
|
230
|
+
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
231
|
+
<img
|
|
232
|
+
ref={imgRef}
|
|
233
|
+
className={`ermis-attachment__link-image${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
234
|
+
src={image}
|
|
235
|
+
alt={title || 'preview'}
|
|
236
|
+
loading="lazy"
|
|
237
|
+
onLoad={() => setLoaded(true)}
|
|
238
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease', display: 'block', width: '100%', height: '100%', objectFit: 'cover', position: 'absolute', top: 0, left: 0 }}
|
|
239
|
+
/>
|
|
240
|
+
</div>
|
|
241
|
+
)}
|
|
242
|
+
<div className="ermis-attachment__link-info">
|
|
243
|
+
{title && <span className="ermis-attachment__link-title">{title}</span>}
|
|
244
|
+
{description && <span className="ermis-attachment__link-description">{description}</span>}
|
|
245
|
+
{url && (
|
|
246
|
+
<span className="ermis-attachment__link-url">
|
|
247
|
+
{new URL(url).hostname}
|
|
248
|
+
</span>
|
|
249
|
+
)}
|
|
250
|
+
</div>
|
|
251
|
+
</a>
|
|
252
|
+
);
|
|
253
|
+
}, (prev, next) => {
|
|
254
|
+
return (prev.attachment.link_url || prev.attachment.og_scrape_url || prev.attachment.url) ===
|
|
255
|
+
(next.attachment.link_url || next.attachment.og_scrape_url || next.attachment.url);
|
|
256
|
+
});
|
|
257
|
+
(LinkPreviewAttachment as any).displayName = 'LinkPreviewAttachment';
|
|
258
|
+
|
|
259
|
+
export const MessageAttachment: React.FC<AttachmentProps> = ({ attachment }) => {
|
|
260
|
+
if (isImage(attachment)) return <ImageAttachment attachment={attachment} />;
|
|
261
|
+
if (isVideo(attachment)) return <VideoAttachment attachment={attachment} />;
|
|
262
|
+
if (isVoiceRecording(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
|
|
263
|
+
if (isLinkPreview(attachment)) return <LinkPreviewAttachment attachment={attachment} />;
|
|
264
|
+
return <FileAttachment attachment={attachment} />;
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.memo(({ attachments }) => {
|
|
268
|
+
if (!attachments || attachments.length === 0) return null;
|
|
269
|
+
|
|
270
|
+
// Group by type
|
|
271
|
+
const media = attachments.filter((a) => isImage(a) || isVideo(a));
|
|
272
|
+
const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !isVoiceRecording(a) && !isLinkPreview(a));
|
|
273
|
+
const voices = attachments.filter(isVoiceRecording);
|
|
274
|
+
const links = attachments.filter(isLinkPreview);
|
|
275
|
+
|
|
276
|
+
const mediaGridClass = media.length === 1
|
|
277
|
+
? 'ermis-attachment-grid ermis-attachment-grid--single'
|
|
278
|
+
: 'ermis-attachment-grid ermis-attachment-grid--multi';
|
|
279
|
+
|
|
280
|
+
return (
|
|
281
|
+
<div className="ermis-attachment-list">
|
|
282
|
+
{/* Media group: images + videos in grid */}
|
|
283
|
+
{media.length > 0 && (
|
|
284
|
+
<div className={mediaGridClass}>
|
|
285
|
+
{media.map((att, i) => (
|
|
286
|
+
isImage(att)
|
|
287
|
+
? <ImageAttachment key={att.id || `img-${i}`} attachment={att} />
|
|
288
|
+
: <VideoAttachment key={att.id || `vid-${i}`} attachment={att} />
|
|
289
|
+
))}
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
{/* File group */}
|
|
293
|
+
{files.map((att, i) => (
|
|
294
|
+
<FileAttachment key={att.id || `file-${i}`} attachment={att} />
|
|
295
|
+
))}
|
|
296
|
+
{/* Voice recording group */}
|
|
297
|
+
{voices.map((att, i) => (
|
|
298
|
+
<VoiceRecordingAttachment key={att.id || `voice-${i}`} attachment={att} />
|
|
299
|
+
))}
|
|
300
|
+
{/* Link preview group */}
|
|
301
|
+
{links.map((att, i) => (
|
|
302
|
+
<LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
|
|
303
|
+
))}
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}, (prev, next) => {
|
|
307
|
+
// Skip re-render if same attachment array reference
|
|
308
|
+
if (prev.attachments === next.attachments) return true;
|
|
309
|
+
if (!prev.attachments || !next.attachments) return false;
|
|
310
|
+
if (prev.attachments.length !== next.attachments.length) return false;
|
|
311
|
+
return prev.attachments.every((a, i) => a.id === next.attachments![i].id);
|
|
312
|
+
});
|
|
313
|
+
(AttachmentList as any).displayName = 'AttachmentList';
|
|
314
|
+
|
|
315
|
+
/* ----------------------------------------------------------
|
|
316
|
+
Message renderers by MessageLabel type
|
|
317
|
+
---------------------------------------------------------- */
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Detect URLs and emails in plain text, wrapping them in <a> tags.
|
|
321
|
+
* Returns an array of React nodes (strings and link elements).
|
|
322
|
+
*/
|
|
323
|
+
const URL_REGEX = /(https?:\/\/[^\s<>]+|www\.[^\s<>]+|[a-zA-Z0-9._%+\-]+@[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,})/g;
|
|
324
|
+
|
|
325
|
+
function linkifyText(text: string, keyPrefix: string): React.ReactNode[] {
|
|
326
|
+
const parts = text.split(URL_REGEX);
|
|
327
|
+
if (parts.length === 1) return [text];
|
|
328
|
+
|
|
329
|
+
return parts.map((part, i) => {
|
|
330
|
+
if (URL_REGEX.test(part)) {
|
|
331
|
+
// Reset lastIndex since we reuse the regex
|
|
332
|
+
URL_REGEX.lastIndex = 0;
|
|
333
|
+
const isEmail = part.includes('@') && !part.startsWith('http');
|
|
334
|
+
const href = isEmail ? `mailto:${part}` : (part.startsWith('http') ? part : `https://${part}`);
|
|
335
|
+
return (
|
|
336
|
+
<a
|
|
337
|
+
key={`${keyPrefix}-link-${i}`}
|
|
338
|
+
className="ermis-text-link"
|
|
339
|
+
href={href}
|
|
340
|
+
target="_blank"
|
|
341
|
+
rel="noopener noreferrer"
|
|
342
|
+
>
|
|
343
|
+
{part}
|
|
344
|
+
</a>
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
// Reset lastIndex
|
|
348
|
+
URL_REGEX.lastIndex = 0;
|
|
349
|
+
return part;
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Parse message text: render @mentions as highlighted spans,
|
|
355
|
+
* and auto-detect URLs/emails in non-mention text parts.
|
|
356
|
+
*/
|
|
357
|
+
function renderTextWithMentions(
|
|
358
|
+
text: string,
|
|
359
|
+
message: FormatMessageResponse,
|
|
360
|
+
userMap: Record<string, string>,
|
|
361
|
+
): React.ReactNode {
|
|
362
|
+
const mentionedUsers: string[] = (message as any).mentioned_users ?? [];
|
|
363
|
+
const mentionedAll: boolean = (message as any).mentioned_all ?? false;
|
|
364
|
+
|
|
365
|
+
// If no mentions, just linkify the text
|
|
366
|
+
if (mentionedUsers.length === 0 && !mentionedAll) {
|
|
367
|
+
return linkifyText(text, 'txt');
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// Build a list of patterns to replace: @userId → @userName
|
|
371
|
+
const replacements: { pattern: string; label: string }[] = [];
|
|
372
|
+
|
|
373
|
+
for (const userId of mentionedUsers) {
|
|
374
|
+
replacements.push({
|
|
375
|
+
pattern: `@${userId}`,
|
|
376
|
+
label: `@${userMap[userId] ?? userId}`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
if (mentionedAll) {
|
|
381
|
+
replacements.push({ pattern: '@all', label: '@all' });
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Build a regex that matches any of the mention patterns
|
|
385
|
+
const escaped = replacements.map((r) =>
|
|
386
|
+
r.pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
|
|
387
|
+
);
|
|
388
|
+
const regex = new RegExp(`(${escaped.join('|')})`, 'g');
|
|
389
|
+
|
|
390
|
+
const parts = text.split(regex);
|
|
391
|
+
|
|
392
|
+
// Map from pattern → label for quick lookup
|
|
393
|
+
const patternToLabel = new Map(replacements.map((r) => [r.pattern, r.label]));
|
|
394
|
+
|
|
395
|
+
return parts.flatMap((part, i) => {
|
|
396
|
+
const label = patternToLabel.get(part);
|
|
397
|
+
if (label) {
|
|
398
|
+
// Mention — render as span, do NOT linkify
|
|
399
|
+
return (
|
|
400
|
+
<span key={`mention-${i}`} className="ermis-mention">
|
|
401
|
+
{label}
|
|
402
|
+
</span>
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
// Non-mention text — linkify URLs/emails
|
|
406
|
+
return linkifyText(part, `p${i}`);
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** Regular message: text with @mentions + attachments */
|
|
411
|
+
export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ message }) => {
|
|
412
|
+
const { activeChannel } = useChatClient();
|
|
413
|
+
|
|
414
|
+
const userMap = useMemo<Record<string, string>>(() => {
|
|
415
|
+
return buildUserMap(activeChannel?.state);
|
|
416
|
+
}, [activeChannel?.state]);
|
|
417
|
+
|
|
418
|
+
const textContent = message.text
|
|
419
|
+
? renderTextWithMentions(message.text, message, userMap)
|
|
420
|
+
: null;
|
|
421
|
+
|
|
422
|
+
const attachmentsToRender = useMemo(() => {
|
|
423
|
+
if (!message.attachments || message.attachments.length === 0) return [];
|
|
424
|
+
|
|
425
|
+
const text = (message.text || '').trim();
|
|
426
|
+
const URL_REGEX_STRICT = /^(https?:\/\/[^\s<>]+|www\.[^\s<>]+)$/;
|
|
427
|
+
const isOnlyUrl = URL_REGEX_STRICT.test(text);
|
|
428
|
+
|
|
429
|
+
return message.attachments.filter(att => {
|
|
430
|
+
if (isLinkPreview(att)) return isOnlyUrl;
|
|
431
|
+
return true;
|
|
432
|
+
});
|
|
433
|
+
}, [message.attachments, message.text]);
|
|
434
|
+
|
|
435
|
+
const hasAttachments = attachmentsToRender.length > 0;
|
|
436
|
+
|
|
437
|
+
if (hasAttachments) {
|
|
438
|
+
return (
|
|
439
|
+
<div className="ermis-message-content--with-attachments">
|
|
440
|
+
{textContent && (
|
|
441
|
+
<span className="ermis-message-list__item-text">{textContent}</span>
|
|
442
|
+
)}
|
|
443
|
+
<AttachmentList attachments={attachmentsToRender} />
|
|
444
|
+
</div>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return (
|
|
449
|
+
<>
|
|
450
|
+
{textContent && (
|
|
451
|
+
<span className="ermis-message-list__item-text">{textContent}</span>
|
|
452
|
+
)}
|
|
453
|
+
</>
|
|
454
|
+
);
|
|
455
|
+
}, (prev, next) => {
|
|
456
|
+
return prev.message.id === next.message.id &&
|
|
457
|
+
prev.message.updated_at === next.message.updated_at &&
|
|
458
|
+
prev.message.text === next.message.text &&
|
|
459
|
+
prev.isOwnMessage === next.isOwnMessage;
|
|
460
|
+
});
|
|
461
|
+
RegularMessage.displayName = 'RegularMessage';
|
|
462
|
+
|
|
463
|
+
/** System message: centered info text, parsed from raw format */
|
|
464
|
+
export const SystemMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
465
|
+
const { activeChannel } = useChatClient();
|
|
466
|
+
|
|
467
|
+
const userMap = useMemo<Record<string, string>>(() => {
|
|
468
|
+
return buildUserMap(activeChannel?.state);
|
|
469
|
+
}, [activeChannel?.state]);
|
|
470
|
+
|
|
471
|
+
const parsedText = useMemo(
|
|
472
|
+
() => (message.text ? parseSystemMessage(message.text, userMap) : ''),
|
|
473
|
+
[message.text, userMap],
|
|
474
|
+
);
|
|
475
|
+
|
|
476
|
+
return (
|
|
477
|
+
<span className="ermis-message-list__system-text">
|
|
478
|
+
{parsedText || message.text}
|
|
479
|
+
</span>
|
|
480
|
+
);
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
/** Signal message: call events */
|
|
484
|
+
export const SignalMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
485
|
+
const { activeChannel } = useChatClient();
|
|
486
|
+
|
|
487
|
+
const userMap = useMemo<Record<string, string>>(() => {
|
|
488
|
+
return buildUserMap(activeChannel?.state);
|
|
489
|
+
}, [activeChannel?.state]);
|
|
490
|
+
|
|
491
|
+
const rawText = message.text ?? '';
|
|
492
|
+
const parsedText = rawText ? parseSignalMessage(rawText, userMap) : '';
|
|
493
|
+
|
|
494
|
+
return (
|
|
495
|
+
<span className="ermis-message-list__signal-text">
|
|
496
|
+
{parsedText || rawText}
|
|
497
|
+
</span>
|
|
498
|
+
);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
/** Poll message */
|
|
502
|
+
export const PollMessage: React.FC<MessageRendererProps> = ({ message }) => (
|
|
503
|
+
<div className="ermis-message-poll">
|
|
504
|
+
<span className="ermis-message-poll__icon">📊</span>
|
|
505
|
+
<span className="ermis-message-poll__text">{message.text || 'Poll'}</span>
|
|
506
|
+
</div>
|
|
507
|
+
);
|
|
508
|
+
|
|
509
|
+
/** Sticker message */
|
|
510
|
+
export const StickerMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
511
|
+
const stickerUrl = (message as any).sticker_url;
|
|
512
|
+
|
|
513
|
+
const alreadyCached = stickerUrl ? isImagePreloaded(stickerUrl) : false;
|
|
514
|
+
const [loaded, setLoaded] = useState(alreadyCached);
|
|
515
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
516
|
+
|
|
517
|
+
useMemo(() => {
|
|
518
|
+
if (stickerUrl) preloadImage(stickerUrl);
|
|
519
|
+
}, [stickerUrl]);
|
|
520
|
+
|
|
521
|
+
React.useEffect(() => {
|
|
522
|
+
if (!loaded && imgRef.current?.complete) {
|
|
523
|
+
setLoaded(true);
|
|
524
|
+
}
|
|
525
|
+
}, [loaded, stickerUrl]);
|
|
526
|
+
|
|
527
|
+
if (stickerUrl) {
|
|
528
|
+
return (
|
|
529
|
+
<div style={{ position: 'relative', width: '120px', height: '120px', overflow: 'hidden' }}>
|
|
530
|
+
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
531
|
+
<img
|
|
532
|
+
ref={imgRef}
|
|
533
|
+
className="ermis-message-sticker"
|
|
534
|
+
src={stickerUrl}
|
|
535
|
+
alt="sticker"
|
|
536
|
+
loading="lazy"
|
|
537
|
+
onLoad={() => setLoaded(true)}
|
|
538
|
+
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease', position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'contain' }}
|
|
539
|
+
/>
|
|
540
|
+
</div>
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
return <span className="ermis-message-list__item-text">{message.text}</span>;
|
|
544
|
+
};
|
|
545
|
+
|
|
546
|
+
/** Error message */
|
|
547
|
+
export const ErrorMessage: React.FC<MessageRendererProps> = ({ message }) => (
|
|
548
|
+
<span className="ermis-message-error">{message.text || 'Message failed'}</span>
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* Map from MessageLabel → component.
|
|
553
|
+
* Consumer can override individual renderers via the `messageRenderers` prop.
|
|
554
|
+
*/
|
|
555
|
+
export const defaultMessageRenderers: Record<
|
|
556
|
+
MessageLabel,
|
|
557
|
+
React.ComponentType<MessageRendererProps>
|
|
558
|
+
> = {
|
|
559
|
+
regular: RegularMessage,
|
|
560
|
+
system: SystemMessage,
|
|
561
|
+
signal: SignalMessage,
|
|
562
|
+
poll: PollMessage,
|
|
563
|
+
sticker: StickerMessage,
|
|
564
|
+
error: ErrorMessage,
|
|
565
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import type { ModalProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export const Modal: React.FC<ModalProps> = ({
|
|
5
|
+
isOpen,
|
|
6
|
+
onClose,
|
|
7
|
+
title,
|
|
8
|
+
children,
|
|
9
|
+
footer,
|
|
10
|
+
maxWidth = '480px',
|
|
11
|
+
hideCloseButton = false,
|
|
12
|
+
}) => {
|
|
13
|
+
useEffect(() => {
|
|
14
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
15
|
+
if (e.key === 'Escape' && isOpen) onClose();
|
|
16
|
+
};
|
|
17
|
+
document.addEventListener('keydown', handleKey);
|
|
18
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
19
|
+
}, [isOpen, onClose]);
|
|
20
|
+
|
|
21
|
+
if (!isOpen) return null;
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<div className="ermis-modal-overlay" onClick={onClose}>
|
|
25
|
+
<div
|
|
26
|
+
className="ermis-modal-content"
|
|
27
|
+
style={{ maxWidth }}
|
|
28
|
+
onClick={e => e.stopPropagation()}
|
|
29
|
+
>
|
|
30
|
+
{(title || !hideCloseButton) && (
|
|
31
|
+
<div className="ermis-modal-header">
|
|
32
|
+
{title ? (
|
|
33
|
+
typeof title === 'string' ? <h3>{title}</h3> : title
|
|
34
|
+
) : <div />}
|
|
35
|
+
{!hideCloseButton && (
|
|
36
|
+
<button className="ermis-modal-close" onClick={onClose} aria-label="Close">
|
|
37
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
38
|
+
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
39
|
+
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
40
|
+
</svg>
|
|
41
|
+
</button>
|
|
42
|
+
)}
|
|
43
|
+
</div>
|
|
44
|
+
)}
|
|
45
|
+
|
|
46
|
+
<div className="ermis-modal-body">
|
|
47
|
+
{children}
|
|
48
|
+
</div>
|
|
49
|
+
|
|
50
|
+
{footer && (
|
|
51
|
+
<div className="ermis-modal-footer">
|
|
52
|
+
{footer}
|
|
53
|
+
</div>
|
|
54
|
+
)}
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
};
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
|
|
3
|
+
export type PanelProps = {
|
|
4
|
+
/** Whether the panel is visible */
|
|
5
|
+
isOpen: boolean;
|
|
6
|
+
/** Called when user clicks the back button */
|
|
7
|
+
onClose: () => void;
|
|
8
|
+
/** Panel title shown in the header */
|
|
9
|
+
title?: string;
|
|
10
|
+
/** Panel body content */
|
|
11
|
+
children: React.ReactNode;
|
|
12
|
+
/** Optional header content (replaces default title + back button) */
|
|
13
|
+
headerContent?: React.ReactNode;
|
|
14
|
+
/** Additional CSS class name */
|
|
15
|
+
className?: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Reusable sliding panel component.
|
|
20
|
+
* Slides in from the right to overlay itself on whatever container it's placed in.
|
|
21
|
+
* Use it like a Modal but inside a sidebar — call `isOpen` to show/hide.
|
|
22
|
+
*/
|
|
23
|
+
export const Panel: React.FC<PanelProps> = React.memo(({
|
|
24
|
+
isOpen,
|
|
25
|
+
onClose,
|
|
26
|
+
title,
|
|
27
|
+
children,
|
|
28
|
+
headerContent,
|
|
29
|
+
className,
|
|
30
|
+
}) => {
|
|
31
|
+
const panelRef = useRef<HTMLDivElement>(null);
|
|
32
|
+
|
|
33
|
+
// Focus trap: focus the panel when it opens for accessibility
|
|
34
|
+
useEffect(() => {
|
|
35
|
+
if (isOpen && panelRef.current) {
|
|
36
|
+
panelRef.current.focus();
|
|
37
|
+
}
|
|
38
|
+
}, [isOpen]);
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div
|
|
42
|
+
ref={panelRef}
|
|
43
|
+
className={`ermis-panel${isOpen ? ' ermis-panel--open' : ''}${className ? ` ${className}` : ''}`}
|
|
44
|
+
tabIndex={-1}
|
|
45
|
+
>
|
|
46
|
+
{headerContent ? (
|
|
47
|
+
headerContent
|
|
48
|
+
) : (
|
|
49
|
+
<div className="ermis-panel__header">
|
|
50
|
+
<button className="ermis-panel__back" onClick={onClose} aria-label="Back">
|
|
51
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
52
|
+
<polyline points="15 18 9 12 15 6" />
|
|
53
|
+
</svg>
|
|
54
|
+
</button>
|
|
55
|
+
{title && <h3 className="ermis-panel__title">{title}</h3>}
|
|
56
|
+
</div>
|
|
57
|
+
)}
|
|
58
|
+
<div className="ermis-panel__body">
|
|
59
|
+
{children}
|
|
60
|
+
</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
});
|
|
64
|
+
Panel.displayName = 'Panel';
|