@ermis-network/ermis-chat-react 1.0.6 → 1.0.8
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 +3802 -1772
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +836 -25
- package/dist/index.css.map +1 -1
- package/dist/index.d.mts +304 -1
- package/dist/index.d.ts +304 -1
- package/dist/index.mjs +3755 -1761
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/channelRoleUtils.ts +73 -0
- package/src/channelTypeUtils.ts +46 -0
- package/src/components/Avatar.tsx +57 -31
- package/src/components/BannedOverlay.tsx +40 -0
- package/src/components/ChannelActions.tsx +233 -0
- package/src/components/ChannelHeader.tsx +126 -5
- package/src/components/ChannelInfo/ChannelInfo.tsx +128 -24
- package/src/components/ChannelInfo/ChannelInfoTabs.tsx +67 -28
- package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +90 -1
- package/src/components/ChannelInfo/EditChannelModal.tsx +5 -4
- package/src/components/ChannelInfo/MemberListItem.tsx +2 -1
- package/src/components/ChannelList.tsx +514 -47
- package/src/components/ClosedTopicOverlay.tsx +38 -0
- package/src/components/CreateChannelModal.tsx +53 -16
- package/src/components/EditPreview.tsx +2 -1
- package/src/components/ForwardMessageModal.tsx +2 -1
- package/src/components/MediaLightbox.tsx +314 -0
- package/src/components/MessageInput.tsx +21 -3
- package/src/components/MessageItem.tsx +10 -12
- package/src/components/MessageQuickReactions.tsx +3 -2
- package/src/components/MessageReactions.tsx +8 -3
- package/src/components/MessageRenderers.tsx +174 -54
- package/src/components/PendingOverlay.tsx +51 -0
- package/src/components/PinnedMessages.tsx +2 -1
- package/src/components/ReplyPreview.tsx +2 -1
- package/src/components/SkippedOverlay.tsx +36 -0
- package/src/components/TopicModal.tsx +189 -0
- package/src/components/UserPicker.tsx +1 -1
- package/src/components/VirtualMessageList.tsx +162 -47
- package/src/hooks/useBannedState.ts +27 -3
- package/src/hooks/useBlockedState.ts +3 -2
- package/src/hooks/useChannelCapabilities.ts +10 -8
- package/src/hooks/useChannelData.ts +1 -1
- package/src/hooks/useChannelListUpdates.ts +28 -5
- package/src/hooks/useChannelMessages.ts +2 -3
- package/src/hooks/useChannelRowUpdates.ts +9 -2
- package/src/hooks/useMessageActions.ts +23 -9
- package/src/hooks/useOnlineStatus.ts +71 -0
- package/src/hooks/useOnlineUsers.ts +115 -0
- package/src/hooks/usePendingState.ts +8 -3
- package/src/index.ts +67 -10
- package/src/messageTypeUtils.ts +64 -0
- package/src/styles/_channel-info.css +21 -0
- package/src/styles/_channel-list.css +276 -6
- package/src/styles/_media-lightbox.css +263 -0
- package/src/styles/_message-bubble.css +170 -13
- package/src/styles/_message-input.css +24 -0
- package/src/styles/_message-list.css +76 -6
- package/src/styles/_message-quick-reactions.css +5 -0
- package/src/styles/_message-reactions.css +7 -0
- package/src/styles/_topic-modal.css +154 -0
- package/src/styles/index.css +2 -0
- package/src/types.ts +203 -3
|
@@ -1,39 +1,25 @@
|
|
|
1
|
-
import React, { useState, useMemo } from 'react';
|
|
1
|
+
import React, { useState, useMemo, useCallback } from 'react';
|
|
2
2
|
import { preloadImage, isImagePreloaded } from '../utils';
|
|
3
3
|
import type { FormatMessageResponse, Attachment, MessageLabel } from '@ermis-network/ermis-chat-sdk';
|
|
4
4
|
import { parseSystemMessage, parseSignalMessage, CallType } from '@ermis-network/ermis-chat-sdk';
|
|
5
5
|
import { useChatClient } from '../hooks/useChatClient';
|
|
6
6
|
import { buildUserMap } from '../utils';
|
|
7
|
-
import
|
|
7
|
+
import { MediaLightbox } from './MediaLightbox';
|
|
8
|
+
import { getFileIcon } from './ChannelInfo/utils';
|
|
9
|
+
import type { AttachmentProps, MessageRendererProps, MessageBubbleProps, MediaLightboxItem } from '../types';
|
|
8
10
|
|
|
9
11
|
export type { AttachmentProps, MessageRendererProps, MessageBubbleProps } from '../types';
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
}
|
|
12
|
+
import {
|
|
13
|
+
isVoiceRecordingAttachment,
|
|
14
|
+
isLinkPreviewAttachment,
|
|
15
|
+
isImage,
|
|
16
|
+
isVideo
|
|
17
|
+
} from '../messageTypeUtils';
|
|
32
18
|
|
|
33
19
|
/* ----------------------------------------------------------
|
|
34
20
|
Attachment renderers
|
|
35
21
|
---------------------------------------------------------- */
|
|
36
|
-
const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
22
|
+
const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
|
|
37
23
|
const src = attachment.image_url || attachment.thumb_url || attachment.url;
|
|
38
24
|
const thumbSrc = attachment.thumb_url;
|
|
39
25
|
if (!src) return null;
|
|
@@ -51,8 +37,15 @@ const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
51
37
|
}
|
|
52
38
|
}, [loaded, src]);
|
|
53
39
|
|
|
40
|
+
const clickable = Boolean(onClick);
|
|
41
|
+
|
|
54
42
|
return (
|
|
55
|
-
<div
|
|
43
|
+
<div
|
|
44
|
+
className={`ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3${clickable ? ' ermis-attachment--clickable' : ''}`}
|
|
45
|
+
onClick={onClick}
|
|
46
|
+
role={clickable ? 'button' : undefined}
|
|
47
|
+
tabIndex={clickable ? 0 : undefined}
|
|
48
|
+
>
|
|
56
49
|
{/* Blur placeholder: use thumb if available, otherwise shimmer */}
|
|
57
50
|
{!loaded && (
|
|
58
51
|
thumbSrc && thumbSrc !== src ? (
|
|
@@ -74,16 +67,26 @@ const ImageAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
74
67
|
loading="lazy"
|
|
75
68
|
onLoad={() => setLoaded(true)}
|
|
76
69
|
/>
|
|
70
|
+
{clickable && (
|
|
71
|
+
<div className="ermis-attachment__overlay">
|
|
72
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
73
|
+
<circle cx="11" cy="11" r="8" />
|
|
74
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
75
|
+
<line x1="11" y1="8" x2="11" y2="14" />
|
|
76
|
+
<line x1="8" y1="11" x2="14" y2="11" />
|
|
77
|
+
</svg>
|
|
78
|
+
</div>
|
|
79
|
+
)}
|
|
77
80
|
</div>
|
|
78
81
|
);
|
|
79
82
|
}, (prev, next) => {
|
|
80
83
|
const prevSrc = prev.attachment.image_url || prev.attachment.thumb_url || prev.attachment.url;
|
|
81
84
|
const nextSrc = next.attachment.image_url || next.attachment.thumb_url || next.attachment.url;
|
|
82
|
-
return prevSrc === nextSrc;
|
|
85
|
+
return prevSrc === nextSrc && prev.onClick === next.onClick;
|
|
83
86
|
});
|
|
84
87
|
(ImageAttachment as any).displayName = 'ImageAttachment';
|
|
85
88
|
|
|
86
|
-
const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) => {
|
|
89
|
+
const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment, onClick }) => {
|
|
87
90
|
const src = attachment.asset_url || attachment.url;
|
|
88
91
|
const posterSrc = attachment.image_url || attachment.thumb_url;
|
|
89
92
|
const blurThumb = attachment.thumb_url;
|
|
@@ -103,8 +106,53 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
103
106
|
}
|
|
104
107
|
}, [loaded, posterSrc]);
|
|
105
108
|
|
|
109
|
+
const clickable = Boolean(onClick);
|
|
110
|
+
|
|
111
|
+
// When clickable (lightbox mode): show poster thumbnail + play icon overlay
|
|
112
|
+
if (clickable) {
|
|
113
|
+
return (
|
|
114
|
+
<div
|
|
115
|
+
className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3 ermis-attachment--clickable"
|
|
116
|
+
onClick={onClick}
|
|
117
|
+
role="button"
|
|
118
|
+
tabIndex={0}
|
|
119
|
+
>
|
|
120
|
+
{!loaded && (
|
|
121
|
+
blurThumb && blurThumb !== posterSrc ? (
|
|
122
|
+
<img className="ermis-attachment-blur-preview" src={blurThumb} alt="" aria-hidden />
|
|
123
|
+
) : (
|
|
124
|
+
<div className="ermis-attachment-shimmer" />
|
|
125
|
+
)
|
|
126
|
+
)}
|
|
127
|
+
{posterSrc ? (
|
|
128
|
+
<img
|
|
129
|
+
ref={imgRef}
|
|
130
|
+
className={`ermis-attachment ermis-attachment--video-poster${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
131
|
+
src={posterSrc}
|
|
132
|
+
alt={attachment.file_name || 'video'}
|
|
133
|
+
loading="lazy"
|
|
134
|
+
onLoad={() => setLoaded(true)}
|
|
135
|
+
/>
|
|
136
|
+
) : (
|
|
137
|
+
<video
|
|
138
|
+
className={`ermis-attachment ermis-attachment--video${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
139
|
+
src={src}
|
|
140
|
+
preload="metadata"
|
|
141
|
+
onLoadedData={() => setLoaded(true)}
|
|
142
|
+
/>
|
|
143
|
+
)}
|
|
144
|
+
<div className="ermis-attachment__overlay">
|
|
145
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
146
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
147
|
+
</svg>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Default inline video player (no lightbox)
|
|
106
154
|
return (
|
|
107
|
-
<div className="ermis-attachment-aspect-box"
|
|
155
|
+
<div className="ermis-attachment-aspect-box ermis-attachment-aspect-box--4-3">
|
|
108
156
|
{!loaded && (
|
|
109
157
|
blurThumb && blurThumb !== posterSrc ? (
|
|
110
158
|
<img
|
|
@@ -121,7 +169,7 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
121
169
|
<img
|
|
122
170
|
ref={imgRef}
|
|
123
171
|
src={posterSrc}
|
|
124
|
-
|
|
172
|
+
className="ermis-attachment--hidden-loader"
|
|
125
173
|
onLoad={() => setLoaded(true)}
|
|
126
174
|
alt="poster-loader"
|
|
127
175
|
/>
|
|
@@ -133,14 +181,14 @@ const VideoAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =
|
|
|
133
181
|
controls
|
|
134
182
|
preload="metadata"
|
|
135
183
|
onLoadedData={() => {
|
|
136
|
-
|
|
184
|
+
if (!posterSrc) setLoaded(true);
|
|
137
185
|
}}
|
|
138
186
|
/>
|
|
139
187
|
</div>
|
|
140
188
|
);
|
|
141
189
|
}, (prev, next) => {
|
|
142
190
|
return (prev.attachment.asset_url || prev.attachment.url) ===
|
|
143
|
-
(next.attachment.asset_url || next.attachment.url);
|
|
191
|
+
(next.attachment.asset_url || next.attachment.url) && prev.onClick === next.onClick;
|
|
144
192
|
});
|
|
145
193
|
(VideoAttachment as any).displayName = 'VideoAttachment';
|
|
146
194
|
|
|
@@ -148,16 +196,36 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
148
196
|
const url = attachment.url || attachment.asset_url;
|
|
149
197
|
const name = attachment.file_name || attachment.title || 'File';
|
|
150
198
|
const size = attachment.file_size;
|
|
199
|
+
const mimeType = attachment.mime_type || attachment.type || '';
|
|
200
|
+
const ext = name.split('.').pop()?.toUpperCase() || 'FILE';
|
|
201
|
+
const { client } = useChatClient();
|
|
202
|
+
|
|
203
|
+
const handleDownload = useCallback(async (e: React.MouseEvent) => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
e.stopPropagation();
|
|
206
|
+
if (!url) return;
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const blob = await client.downloadMedia(url);
|
|
210
|
+
const urlBlob = window.URL.createObjectURL(blob);
|
|
211
|
+
const a = document.createElement('a');
|
|
212
|
+
a.href = urlBlob;
|
|
213
|
+
a.download = name;
|
|
214
|
+
document.body.appendChild(a);
|
|
215
|
+
a.click();
|
|
216
|
+
a.remove();
|
|
217
|
+
window.URL.revokeObjectURL(urlBlob);
|
|
218
|
+
} catch {
|
|
219
|
+
window.open(url, '_blank', 'noopener,noreferrer');
|
|
220
|
+
}
|
|
221
|
+
}, [client, url, name]);
|
|
151
222
|
|
|
152
223
|
return (
|
|
153
|
-
<
|
|
154
|
-
className="ermis-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
rel="noopener noreferrer"
|
|
159
|
-
>
|
|
160
|
-
<span className="ermis-attachment__file-icon">⬇️</span>
|
|
224
|
+
<div className="ermis-attachment ermis-attachment--file">
|
|
225
|
+
<span className="ermis-attachment__file-icon">
|
|
226
|
+
{getFileIcon(mimeType, name)}
|
|
227
|
+
<span className="ermis-attachment__file-ext">{ext}</span>
|
|
228
|
+
</span>
|
|
161
229
|
<span className="ermis-attachment__file-info">
|
|
162
230
|
<span className="ermis-attachment__file-name">{name}</span>
|
|
163
231
|
{size && (
|
|
@@ -166,7 +234,19 @@ const FileAttachment: React.FC<AttachmentProps> = React.memo(({ attachment }) =>
|
|
|
166
234
|
</span>
|
|
167
235
|
)}
|
|
168
236
|
</span>
|
|
169
|
-
|
|
237
|
+
<button
|
|
238
|
+
className="ermis-attachment__file-download"
|
|
239
|
+
onClick={handleDownload}
|
|
240
|
+
title="Download"
|
|
241
|
+
type="button"
|
|
242
|
+
>
|
|
243
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
244
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
245
|
+
<polyline points="7 10 12 15 17 10" />
|
|
246
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
247
|
+
</svg>
|
|
248
|
+
</button>
|
|
249
|
+
</div>
|
|
170
250
|
);
|
|
171
251
|
}, (prev, next) => {
|
|
172
252
|
return (prev.attachment.url || prev.attachment.asset_url) ===
|
|
@@ -226,7 +306,7 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
226
306
|
rel="noopener noreferrer"
|
|
227
307
|
>
|
|
228
308
|
{image && (
|
|
229
|
-
<div
|
|
309
|
+
<div className="ermis-attachment__link-image-wrapper">
|
|
230
310
|
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
231
311
|
<img
|
|
232
312
|
ref={imgRef}
|
|
@@ -235,7 +315,6 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
235
315
|
alt={title || 'preview'}
|
|
236
316
|
loading="lazy"
|
|
237
317
|
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
318
|
/>
|
|
240
319
|
</div>
|
|
241
320
|
)}
|
|
@@ -259,8 +338,8 @@ const LinkPreviewAttachment: React.FC<AttachmentProps> = React.memo(({ attachmen
|
|
|
259
338
|
export const MessageAttachment: React.FC<AttachmentProps> = ({ attachment }) => {
|
|
260
339
|
if (isImage(attachment)) return <ImageAttachment attachment={attachment} />;
|
|
261
340
|
if (isVideo(attachment)) return <VideoAttachment attachment={attachment} />;
|
|
262
|
-
if (
|
|
263
|
-
if (
|
|
341
|
+
if (isVoiceRecordingAttachment(attachment)) return <VoiceRecordingAttachment attachment={attachment} />;
|
|
342
|
+
if (isLinkPreviewAttachment(attachment)) return <LinkPreviewAttachment attachment={attachment} />;
|
|
264
343
|
return <FileAttachment attachment={attachment} />;
|
|
265
344
|
};
|
|
266
345
|
|
|
@@ -269,9 +348,41 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
269
348
|
|
|
270
349
|
// Group by type
|
|
271
350
|
const media = attachments.filter((a) => isImage(a) || isVideo(a));
|
|
272
|
-
const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !
|
|
273
|
-
const voices = attachments.filter(
|
|
274
|
-
const links = attachments.filter(
|
|
351
|
+
const files = attachments.filter((a) => !isImage(a) && !isVideo(a) && !isVoiceRecordingAttachment(a) && !isLinkPreviewAttachment(a));
|
|
352
|
+
const voices = attachments.filter(isVoiceRecordingAttachment);
|
|
353
|
+
const links = attachments.filter(isLinkPreviewAttachment);
|
|
354
|
+
|
|
355
|
+
// Lightbox state
|
|
356
|
+
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
357
|
+
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
358
|
+
|
|
359
|
+
// Build lightbox items from media attachments
|
|
360
|
+
const lightboxItems = useMemo<MediaLightboxItem[]>(() => {
|
|
361
|
+
return media.map(att => {
|
|
362
|
+
if (isImage(att)) {
|
|
363
|
+
return {
|
|
364
|
+
type: 'image' as const,
|
|
365
|
+
src: att.image_url || att.thumb_url || att.url || '',
|
|
366
|
+
alt: att.file_name || att.title,
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
return {
|
|
370
|
+
type: 'video' as const,
|
|
371
|
+
src: att.asset_url || att.url || '',
|
|
372
|
+
alt: att.file_name || att.title,
|
|
373
|
+
posterSrc: att.image_url || att.thumb_url,
|
|
374
|
+
};
|
|
375
|
+
});
|
|
376
|
+
}, [media]);
|
|
377
|
+
|
|
378
|
+
const openLightbox = useCallback((index: number) => {
|
|
379
|
+
setLightboxIndex(index);
|
|
380
|
+
setLightboxOpen(true);
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
const closeLightbox = useCallback(() => {
|
|
384
|
+
setLightboxOpen(false);
|
|
385
|
+
}, []);
|
|
275
386
|
|
|
276
387
|
const mediaGridClass = media.length === 1
|
|
277
388
|
? 'ermis-attachment-grid ermis-attachment-grid--single'
|
|
@@ -284,8 +395,8 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
284
395
|
<div className={mediaGridClass}>
|
|
285
396
|
{media.map((att, i) => (
|
|
286
397
|
isImage(att)
|
|
287
|
-
? <ImageAttachment key={att.id || `img-${i}`} attachment={att} />
|
|
288
|
-
: <VideoAttachment key={att.id || `vid-${i}`} attachment={att} />
|
|
398
|
+
? <ImageAttachment key={att.id || `img-${i}`} attachment={att} onClick={() => openLightbox(i)} />
|
|
399
|
+
: <VideoAttachment key={att.id || `vid-${i}`} attachment={att} onClick={() => openLightbox(i)} />
|
|
289
400
|
))}
|
|
290
401
|
</div>
|
|
291
402
|
)}
|
|
@@ -301,6 +412,16 @@ export const AttachmentList: React.FC<{ attachments?: Attachment[] }> = React.me
|
|
|
301
412
|
{links.map((att, i) => (
|
|
302
413
|
<LinkPreviewAttachment key={att.id || `link-${i}`} attachment={att} />
|
|
303
414
|
))}
|
|
415
|
+
|
|
416
|
+
{/* Media Lightbox */}
|
|
417
|
+
{lightboxItems.length > 0 && (
|
|
418
|
+
<MediaLightbox
|
|
419
|
+
items={lightboxItems}
|
|
420
|
+
initialIndex={lightboxIndex}
|
|
421
|
+
isOpen={lightboxOpen}
|
|
422
|
+
onClose={closeLightbox}
|
|
423
|
+
/>
|
|
424
|
+
)}
|
|
304
425
|
</div>
|
|
305
426
|
);
|
|
306
427
|
}, (prev, next) => {
|
|
@@ -427,7 +548,7 @@ export const RegularMessage: React.FC<MessageRendererProps> = React.memo(({ mess
|
|
|
427
548
|
const isOnlyUrl = URL_REGEX_STRICT.test(text);
|
|
428
549
|
|
|
429
550
|
return message.attachments.filter(att => {
|
|
430
|
-
if (
|
|
551
|
+
if (isLinkPreviewAttachment(att)) return isOnlyUrl;
|
|
431
552
|
return true;
|
|
432
553
|
});
|
|
433
554
|
}, [message.attachments, message.text]);
|
|
@@ -553,16 +674,15 @@ export const StickerMessage: React.FC<MessageRendererProps> = ({ message }) => {
|
|
|
553
674
|
|
|
554
675
|
if (stickerUrl) {
|
|
555
676
|
return (
|
|
556
|
-
<div
|
|
677
|
+
<div className="ermis-message-sticker-wrapper">
|
|
557
678
|
{!loaded && <div className="ermis-attachment-shimmer" />}
|
|
558
679
|
<img
|
|
559
680
|
ref={imgRef}
|
|
560
|
-
className=
|
|
681
|
+
className={`ermis-message-sticker${loaded ? ' ermis-attachment--loaded' : ''}`}
|
|
561
682
|
src={stickerUrl}
|
|
562
683
|
alt="sticker"
|
|
563
684
|
loading="lazy"
|
|
564
685
|
onLoad={() => setLoaded(true)}
|
|
565
|
-
style={{ opacity: loaded ? 1 : 0, transition: 'opacity 0.3s ease', position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', objectFit: 'contain' }}
|
|
566
686
|
/>
|
|
567
687
|
</div>
|
|
568
688
|
);
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AvatarProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export type PendingOverlayProps = {
|
|
5
|
+
channelImage?: string;
|
|
6
|
+
channelName?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
acceptLabel: string;
|
|
10
|
+
rejectLabel: string;
|
|
11
|
+
onAccept: () => void;
|
|
12
|
+
onReject: () => void;
|
|
13
|
+
/** Label for the skip button (direct messaging channels) */
|
|
14
|
+
skipLabel?: string;
|
|
15
|
+
/** Handler for the skip action (direct messaging channels) */
|
|
16
|
+
onSkip?: () => void;
|
|
17
|
+
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const PendingOverlay: React.FC<PendingOverlayProps> = React.memo(({
|
|
21
|
+
channelImage,
|
|
22
|
+
channelName,
|
|
23
|
+
title,
|
|
24
|
+
subtitle,
|
|
25
|
+
acceptLabel,
|
|
26
|
+
rejectLabel,
|
|
27
|
+
onAccept,
|
|
28
|
+
onReject,
|
|
29
|
+
skipLabel,
|
|
30
|
+
onSkip,
|
|
31
|
+
AvatarComponent,
|
|
32
|
+
}) => (
|
|
33
|
+
<div className="ermis-message-list__pending-overlay">
|
|
34
|
+
<div className="ermis-message-list__pending-card">
|
|
35
|
+
<AvatarComponent image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
36
|
+
<span className="ermis-message-list__pending-overlay-title">{title}</span>
|
|
37
|
+
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
38
|
+
<span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
|
|
39
|
+
<div className="ermis-message-list__pending-actions">
|
|
40
|
+
{onSkip ? (
|
|
41
|
+
<button className="ermis-message-list__reject-btn" onClick={onSkip}>{skipLabel || 'Skip'}</button>
|
|
42
|
+
) : (
|
|
43
|
+
<button className="ermis-message-list__reject-btn" onClick={onReject}>{rejectLabel}</button>
|
|
44
|
+
)}
|
|
45
|
+
<button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
));
|
|
50
|
+
|
|
51
|
+
PendingOverlay.displayName = 'PendingOverlay';
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { Avatar } from './Avatar';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
5
6
|
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
6
7
|
import type { PinnedMessageItemProps, PinnedMessagesProps } from '../types';
|
|
@@ -25,7 +26,7 @@ const DefaultPinnedMessageItem: React.FC<PinnedMessageItemProps> = React.memo(({
|
|
|
25
26
|
}, [activeChannel?.state]);
|
|
26
27
|
|
|
27
28
|
let previewText = message.text || '';
|
|
28
|
-
const isSticker = message
|
|
29
|
+
const isSticker = isStickerMessage(message);
|
|
29
30
|
|
|
30
31
|
if (!previewText && hasAttachments) {
|
|
31
32
|
const firstAttach = message.attachments![0];
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import React, { useMemo } from 'react';
|
|
2
2
|
import { useChatClient } from '../hooks/useChatClient';
|
|
3
3
|
import { replaceMentionsForPreview, buildUserMap } from '../utils';
|
|
4
|
+
import { isStickerMessage } from '../messageTypeUtils';
|
|
4
5
|
import type { ReplyPreviewProps } from '../types';
|
|
5
6
|
|
|
6
7
|
const MAX_PREVIEW_LENGTH = 120;
|
|
@@ -53,7 +54,7 @@ export const ReplyPreview: React.FC<ReplyPreviewProps> = React.memo(({
|
|
|
53
54
|
const formattedText = useMemo(() => replaceMentionsForPreview(rawText, message, userMap), [rawText, message, userMap]);
|
|
54
55
|
const hasText = !!formattedText.trim();
|
|
55
56
|
const hasAttachments = message.attachments && message.attachments.length > 0;
|
|
56
|
-
const isSticker = message
|
|
57
|
+
const isSticker = isStickerMessage(message);
|
|
57
58
|
const attachmentSummary = hasAttachments ? getAttachmentSummary(message.attachments!) : '';
|
|
58
59
|
|
|
59
60
|
// Build preview content
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import type { AvatarProps } from '../types';
|
|
3
|
+
|
|
4
|
+
export type SkippedOverlayProps = {
|
|
5
|
+
channelImage?: string;
|
|
6
|
+
channelName?: string;
|
|
7
|
+
title: string;
|
|
8
|
+
subtitle: string;
|
|
9
|
+
acceptLabel: string;
|
|
10
|
+
onAccept: () => void;
|
|
11
|
+
AvatarComponent: React.ComponentType<AvatarProps>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export const SkippedOverlay: React.FC<SkippedOverlayProps> = React.memo(({
|
|
15
|
+
channelImage,
|
|
16
|
+
channelName,
|
|
17
|
+
title,
|
|
18
|
+
subtitle,
|
|
19
|
+
acceptLabel,
|
|
20
|
+
onAccept,
|
|
21
|
+
AvatarComponent,
|
|
22
|
+
}) => (
|
|
23
|
+
<div className="ermis-message-list__pending-overlay">
|
|
24
|
+
<div className="ermis-message-list__pending-card">
|
|
25
|
+
<AvatarComponent image={channelImage} name={channelName} size={64} className="ermis-message-list__pending-avatar" />
|
|
26
|
+
<span className="ermis-message-list__pending-overlay-title">{title}</span>
|
|
27
|
+
<div className="ermis-message-list__pending-channel-name">{channelName}</div>
|
|
28
|
+
<span className="ermis-message-list__pending-overlay-subtitle">{subtitle}</span>
|
|
29
|
+
<div className="ermis-message-list__pending-actions">
|
|
30
|
+
<button className="ermis-message-list__accept-btn" onClick={onAccept}>{acceptLabel}</button>
|
|
31
|
+
</div>
|
|
32
|
+
</div>
|
|
33
|
+
</div>
|
|
34
|
+
));
|
|
35
|
+
|
|
36
|
+
SkippedOverlay.displayName = 'SkippedOverlay';
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { useState, useCallback } from 'react';
|
|
2
|
+
import type { CreateTopicData, EditTopicData } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { Modal } from './Modal';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import type { TopicModalProps } from '../types';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_TOPIC_ICONS = ['💬', '🔥', '🚀', '⭐', '💡', '🎉', '📌', '📁', '🎨', '💻', '📈', '🤝'];
|
|
8
|
+
|
|
9
|
+
export const TopicModal: React.FC<TopicModalProps> = React.memo(({
|
|
10
|
+
isOpen,
|
|
11
|
+
onClose,
|
|
12
|
+
onSuccess,
|
|
13
|
+
EmojiPickerComponent,
|
|
14
|
+
parentChannel,
|
|
15
|
+
topic,
|
|
16
|
+
title = topic ? 'Edit Topic' : 'Create Topic',
|
|
17
|
+
nameLabel = 'Topic Name',
|
|
18
|
+
namePlaceholder = 'Enter topic name',
|
|
19
|
+
emojiLabel = 'Topic icon',
|
|
20
|
+
descriptionLabel = 'Description',
|
|
21
|
+
descriptionPlaceholder = 'Enter topic description',
|
|
22
|
+
cancelButtonLabel = 'Cancel',
|
|
23
|
+
saveButtonLabel = topic ? 'Save' : 'Create',
|
|
24
|
+
savingButtonLabel = topic ? 'Saving...' : 'Creating...',
|
|
25
|
+
}) => {
|
|
26
|
+
const { activeChannel, client } = useChatClient();
|
|
27
|
+
|
|
28
|
+
const originalName = (topic?.data?.name as string) || '';
|
|
29
|
+
const originalImage = (topic?.data?.image as string) || '';
|
|
30
|
+
const originalEmoji = originalImage.startsWith('emoji://') ? originalImage.replace('emoji://', '') : '';
|
|
31
|
+
const originalDescription = (topic?.data?.description as string) || '';
|
|
32
|
+
|
|
33
|
+
const [name, setName] = useState(originalName);
|
|
34
|
+
const [emoji, setEmoji] = useState(originalEmoji);
|
|
35
|
+
const [description, setDescription] = useState(originalDescription);
|
|
36
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
37
|
+
const [error, setError] = useState<string | null>(null);
|
|
38
|
+
|
|
39
|
+
const targetParent = parentChannel || activeChannel;
|
|
40
|
+
|
|
41
|
+
const handleSave = useCallback(async () => {
|
|
42
|
+
if (!name.trim() || !emoji) return;
|
|
43
|
+
|
|
44
|
+
// Resolve parent channel (owner of topics)
|
|
45
|
+
let editorParent = targetParent;
|
|
46
|
+
if (topic && topic.data?.parent_cid) {
|
|
47
|
+
editorParent = client.activeChannels[topic.data.parent_cid as string] || editorParent;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (!editorParent && !topic) return;
|
|
51
|
+
|
|
52
|
+
setIsSaving(true);
|
|
53
|
+
setError(null);
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
if (topic) {
|
|
57
|
+
if (!editorParent) throw new Error("Parent channel not found");
|
|
58
|
+
|
|
59
|
+
const payload: EditTopicData = {};
|
|
60
|
+
if (name.trim() !== originalName) payload.name = name.trim();
|
|
61
|
+
if (emoji !== originalEmoji) payload.image = emoji ? `emoji://${emoji}` : '';
|
|
62
|
+
if (description.trim() !== originalDescription) payload.description = description.trim();
|
|
63
|
+
|
|
64
|
+
if (Object.keys(payload).length > 0 && topic.cid) {
|
|
65
|
+
await editorParent.editTopic(topic.cid, payload);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (onSuccess) {
|
|
69
|
+
onSuccess(topic);
|
|
70
|
+
} else {
|
|
71
|
+
onClose();
|
|
72
|
+
}
|
|
73
|
+
} else {
|
|
74
|
+
if (!editorParent) return;
|
|
75
|
+
const payload: CreateTopicData = {
|
|
76
|
+
name: name.trim(),
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (emoji) {
|
|
80
|
+
payload.image = `emoji://${emoji}`;
|
|
81
|
+
}
|
|
82
|
+
if (description.trim()) {
|
|
83
|
+
payload.description = description.trim();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
await editorParent.createTopic(payload);
|
|
87
|
+
|
|
88
|
+
if (onSuccess) {
|
|
89
|
+
onSuccess(editorParent);
|
|
90
|
+
} else {
|
|
91
|
+
onClose();
|
|
92
|
+
setName('');
|
|
93
|
+
setEmoji('');
|
|
94
|
+
setDescription('');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
} catch (err: any) {
|
|
98
|
+
setError(err?.message || (topic ? 'Failed to save topic' : 'Failed to create topic'));
|
|
99
|
+
} finally {
|
|
100
|
+
setIsSaving(false);
|
|
101
|
+
}
|
|
102
|
+
}, [targetParent, topic, name, emoji, description, originalName, originalEmoji, originalDescription, onSuccess, onClose, client.activeChannels]);
|
|
103
|
+
|
|
104
|
+
const isValid = name.trim().length > 0 && emoji.length > 0;
|
|
105
|
+
|
|
106
|
+
const footer = (
|
|
107
|
+
<div className="ermis-create-topic__footer">
|
|
108
|
+
<button className="ermis-create-topic__btn ermis-create-topic__btn--cancel" onClick={onClose} disabled={isSaving}>{cancelButtonLabel}</button>
|
|
109
|
+
<button className="ermis-create-topic__btn ermis-create-topic__btn--create" onClick={handleSave} disabled={isSaving || !isValid}>
|
|
110
|
+
{isSaving ? savingButtonLabel : saveButtonLabel}
|
|
111
|
+
</button>
|
|
112
|
+
</div>
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<Modal isOpen={isOpen} onClose={isSaving ? () => { } : onClose} title={title} maxWidth="400px" footer={footer}>
|
|
117
|
+
<div className="ermis-create-topic__body">
|
|
118
|
+
<div className="ermis-create-topic__live-preview">
|
|
119
|
+
<span className="ermis-create-topic__live-preview-emoji">{emoji || <span style={{opacity: 0.3}}>#</span>}</span>
|
|
120
|
+
<span className="ermis-create-topic__live-preview-name">{name || namePlaceholder}</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="ermis-create-topic__field">
|
|
124
|
+
<label className="ermis-create-topic__label">{nameLabel} <span className="ermis-create-topic__required">*</span></label>
|
|
125
|
+
<input
|
|
126
|
+
className="ermis-create-topic__input"
|
|
127
|
+
value={name}
|
|
128
|
+
onChange={(e) => setName(e.target.value)}
|
|
129
|
+
placeholder={namePlaceholder}
|
|
130
|
+
disabled={isSaving}
|
|
131
|
+
maxLength={100}
|
|
132
|
+
autoFocus
|
|
133
|
+
/>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div className="ermis-create-topic__field">
|
|
137
|
+
<label className="ermis-create-topic__label">{emojiLabel} <span className="ermis-create-topic__required">*</span></label>
|
|
138
|
+
|
|
139
|
+
<div className="ermis-create-topic__emoji-picker">
|
|
140
|
+
{EmojiPickerComponent ? (
|
|
141
|
+
<EmojiPickerComponent onSelect={(e: any) => setEmoji(e.native || e.emoji || e.id || e)} />
|
|
142
|
+
) : (
|
|
143
|
+
<div className="ermis-create-topic__default-icons">
|
|
144
|
+
{DEFAULT_TOPIC_ICONS.map(icon => (
|
|
145
|
+
<button
|
|
146
|
+
key={icon}
|
|
147
|
+
type="button"
|
|
148
|
+
className={`ermis-create-topic__default-icon ${icon === emoji ? 'ermis-create-topic__default-icon--active' : ''}`}
|
|
149
|
+
onClick={() => setEmoji(icon)}
|
|
150
|
+
disabled={isSaving}
|
|
151
|
+
>
|
|
152
|
+
{icon}
|
|
153
|
+
</button>
|
|
154
|
+
))}
|
|
155
|
+
</div>
|
|
156
|
+
)}
|
|
157
|
+
</div>
|
|
158
|
+
</div>
|
|
159
|
+
|
|
160
|
+
<div className="ermis-create-topic__field">
|
|
161
|
+
<label className="ermis-create-topic__label">{descriptionLabel}</label>
|
|
162
|
+
<textarea
|
|
163
|
+
className="ermis-create-topic__input"
|
|
164
|
+
value={description}
|
|
165
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
166
|
+
placeholder={descriptionPlaceholder}
|
|
167
|
+
disabled={isSaving}
|
|
168
|
+
rows={3}
|
|
169
|
+
maxLength={500}
|
|
170
|
+
style={{ resize: 'vertical' }}
|
|
171
|
+
/>
|
|
172
|
+
</div>
|
|
173
|
+
|
|
174
|
+
{error && (
|
|
175
|
+
<div className="ermis-create-topic__error">
|
|
176
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
177
|
+
<circle cx="12" cy="12" r="10" />
|
|
178
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
179
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
180
|
+
</svg>
|
|
181
|
+
{error}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</Modal>
|
|
186
|
+
);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
TopicModal.displayName = 'TopicModal';
|