@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,272 @@
|
|
|
1
|
+
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import { Modal } from '../Modal';
|
|
3
|
+
import type { EditChannelModalProps, EditChannelData } from '../../types';
|
|
4
|
+
|
|
5
|
+
const DEFAULT_MAX_IMAGE_SIZE = 5 * 1024 * 1024; // 5MB
|
|
6
|
+
|
|
7
|
+
export const EditChannelModal: React.FC<EditChannelModalProps> = React.memo(({
|
|
8
|
+
channel,
|
|
9
|
+
onClose,
|
|
10
|
+
onSave,
|
|
11
|
+
AvatarComponent,
|
|
12
|
+
title = 'Edit Channel',
|
|
13
|
+
nameLabel = 'Channel Name',
|
|
14
|
+
descriptionLabel = 'Description',
|
|
15
|
+
namePlaceholder = 'Enter channel name',
|
|
16
|
+
descriptionPlaceholder = 'Enter channel description',
|
|
17
|
+
publicLabel = 'Public Channel',
|
|
18
|
+
saveLabel = 'Save',
|
|
19
|
+
cancelLabel = 'Cancel',
|
|
20
|
+
savingLabel = 'Saving...',
|
|
21
|
+
changeAvatarLabel = 'Change Avatar',
|
|
22
|
+
imageAccept = 'image/*',
|
|
23
|
+
maxImageSize = DEFAULT_MAX_IMAGE_SIZE,
|
|
24
|
+
maxImageSizeError = 'Image must be less than 5MB',
|
|
25
|
+
}) => {
|
|
26
|
+
// Original values from channel data
|
|
27
|
+
const originalName = (channel.data?.name as string) || '';
|
|
28
|
+
const originalImage = (channel.data?.image as string) || '';
|
|
29
|
+
const originalDescription = (channel.data?.description as string) || '';
|
|
30
|
+
const originalPublic = Boolean(channel.data?.public);
|
|
31
|
+
const isTeamChannel = channel.type === 'team';
|
|
32
|
+
|
|
33
|
+
// Form state
|
|
34
|
+
const [name, setName] = useState(originalName);
|
|
35
|
+
const [description, setDescription] = useState(originalDescription);
|
|
36
|
+
const [isPublic, setIsPublic] = useState(originalPublic);
|
|
37
|
+
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
|
38
|
+
const [selectedFile, setSelectedFile] = useState<File | null>(null);
|
|
39
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
40
|
+
const [error, setError] = useState<string | null>(null);
|
|
41
|
+
|
|
42
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
43
|
+
|
|
44
|
+
// Clean up object URL on unmount or when preview changes
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
return () => {
|
|
47
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
48
|
+
};
|
|
49
|
+
}, [previewUrl]);
|
|
50
|
+
|
|
51
|
+
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
52
|
+
const file = e.target.files?.[0];
|
|
53
|
+
if (!file) return;
|
|
54
|
+
|
|
55
|
+
// Validate it's an image
|
|
56
|
+
if (!file.type.startsWith('image/')) {
|
|
57
|
+
setError('Only image files are allowed');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Validate size
|
|
62
|
+
if (file.size > maxImageSize) {
|
|
63
|
+
setError(maxImageSizeError);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
setError(null);
|
|
68
|
+
// Revoke previous preview
|
|
69
|
+
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
70
|
+
const url = URL.createObjectURL(file);
|
|
71
|
+
setPreviewUrl(url);
|
|
72
|
+
setSelectedFile(file);
|
|
73
|
+
|
|
74
|
+
// Reset input so same file can be re-selected
|
|
75
|
+
e.target.value = '';
|
|
76
|
+
}, [maxImageSize, maxImageSizeError, previewUrl]);
|
|
77
|
+
|
|
78
|
+
const handleAvatarClick = useCallback(() => {
|
|
79
|
+
fileInputRef.current?.click();
|
|
80
|
+
}, []);
|
|
81
|
+
|
|
82
|
+
// Check if anything changed — only send changed fields
|
|
83
|
+
const buildPayload = useCallback((): EditChannelData | null => {
|
|
84
|
+
const payload: EditChannelData = {};
|
|
85
|
+
let hasChanges = false;
|
|
86
|
+
|
|
87
|
+
if (name.trim() !== originalName) {
|
|
88
|
+
payload.name = name.trim();
|
|
89
|
+
hasChanges = true;
|
|
90
|
+
}
|
|
91
|
+
if (description.trim() !== originalDescription) {
|
|
92
|
+
payload.description = description.trim();
|
|
93
|
+
hasChanges = true;
|
|
94
|
+
}
|
|
95
|
+
if (isTeamChannel && isPublic !== originalPublic) {
|
|
96
|
+
payload.public = isPublic;
|
|
97
|
+
hasChanges = true;
|
|
98
|
+
}
|
|
99
|
+
// Image is handled separately (upload first), but mark as changed
|
|
100
|
+
if (selectedFile) {
|
|
101
|
+
hasChanges = true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return hasChanges ? payload : null;
|
|
105
|
+
}, [name, description, isPublic, selectedFile, originalName, originalDescription, originalPublic, isTeamChannel]);
|
|
106
|
+
|
|
107
|
+
const handleSave = useCallback(async () => {
|
|
108
|
+
const payload = buildPayload();
|
|
109
|
+
if (!payload && !selectedFile) {
|
|
110
|
+
onClose();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
setIsSaving(true);
|
|
115
|
+
setError(null);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
// If consumer provides custom save handler, delegate entirely
|
|
119
|
+
if (onSave) {
|
|
120
|
+
if (selectedFile) {
|
|
121
|
+
const response = await channel.sendFile(selectedFile, selectedFile.name, selectedFile.type);
|
|
122
|
+
(payload || {} as EditChannelData).image = response.file;
|
|
123
|
+
}
|
|
124
|
+
await onSave(payload || {});
|
|
125
|
+
onClose();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Default save logic
|
|
130
|
+
const finalPayload: EditChannelData = payload || {};
|
|
131
|
+
|
|
132
|
+
// Upload image if changed
|
|
133
|
+
if (selectedFile) {
|
|
134
|
+
const response = await channel.sendFile(selectedFile, selectedFile.name, selectedFile.type);
|
|
135
|
+
finalPayload.image = response.file;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Only call update if there's something to update
|
|
139
|
+
if (Object.keys(finalPayload).length > 0) {
|
|
140
|
+
await channel.update(finalPayload as any);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
onClose();
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
setError(err?.message || 'Failed to update channel');
|
|
146
|
+
} finally {
|
|
147
|
+
setIsSaving(false);
|
|
148
|
+
}
|
|
149
|
+
}, [buildPayload, selectedFile, onSave, channel, onClose]);
|
|
150
|
+
|
|
151
|
+
// Determine displayed avatar image
|
|
152
|
+
const displayImage = previewUrl || originalImage || undefined;
|
|
153
|
+
|
|
154
|
+
const footerContent = (
|
|
155
|
+
<div className="ermis-channel-info__edit-footer-buttons">
|
|
156
|
+
<button
|
|
157
|
+
className="ermis-channel-info__edit-btn ermis-channel-info__edit-btn--cancel"
|
|
158
|
+
onClick={onClose}
|
|
159
|
+
disabled={isSaving}
|
|
160
|
+
>
|
|
161
|
+
{cancelLabel}
|
|
162
|
+
</button>
|
|
163
|
+
<button
|
|
164
|
+
className="ermis-channel-info__edit-btn ermis-channel-info__edit-btn--save"
|
|
165
|
+
onClick={handleSave}
|
|
166
|
+
disabled={isSaving}
|
|
167
|
+
>
|
|
168
|
+
{isSaving ? savingLabel : saveLabel}
|
|
169
|
+
</button>
|
|
170
|
+
</div>
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
return (
|
|
174
|
+
<Modal
|
|
175
|
+
isOpen={true}
|
|
176
|
+
onClose={isSaving ? () => {} : onClose}
|
|
177
|
+
title={title}
|
|
178
|
+
footer={footerContent}
|
|
179
|
+
maxWidth="420px"
|
|
180
|
+
>
|
|
181
|
+
<div className="ermis-channel-info__edit-body">
|
|
182
|
+
{/* Avatar section */}
|
|
183
|
+
<div className="ermis-channel-info__edit-avatar-section">
|
|
184
|
+
<div className="ermis-channel-info__edit-avatar-wrap" onClick={handleAvatarClick}>
|
|
185
|
+
<AvatarComponent image={displayImage} name={name || originalName} size={80} />
|
|
186
|
+
<div className="ermis-channel-info__edit-avatar-overlay">
|
|
187
|
+
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
188
|
+
<path d="M23 19a2 2 0 0 1-2 2H3a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h4l2-3h6l2 3h4a2 2 0 0 1 2 2z" />
|
|
189
|
+
<circle cx="12" cy="13" r="4" />
|
|
190
|
+
</svg>
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
<button
|
|
194
|
+
className="ermis-channel-info__edit-avatar-btn"
|
|
195
|
+
onClick={handleAvatarClick}
|
|
196
|
+
type="button"
|
|
197
|
+
disabled={isSaving}
|
|
198
|
+
>
|
|
199
|
+
{changeAvatarLabel}
|
|
200
|
+
</button>
|
|
201
|
+
<input
|
|
202
|
+
ref={fileInputRef}
|
|
203
|
+
type="file"
|
|
204
|
+
accept={imageAccept}
|
|
205
|
+
onChange={handleFileSelect}
|
|
206
|
+
style={{ display: 'none' }}
|
|
207
|
+
aria-hidden="true"
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
|
|
211
|
+
{/* Name field */}
|
|
212
|
+
<div className="ermis-channel-info__edit-field">
|
|
213
|
+
<label className="ermis-channel-info__edit-label">{nameLabel}</label>
|
|
214
|
+
<input
|
|
215
|
+
className="ermis-channel-info__edit-input"
|
|
216
|
+
type="text"
|
|
217
|
+
value={name}
|
|
218
|
+
onChange={(e) => setName(e.target.value)}
|
|
219
|
+
placeholder={namePlaceholder}
|
|
220
|
+
disabled={isSaving}
|
|
221
|
+
maxLength={100}
|
|
222
|
+
/>
|
|
223
|
+
</div>
|
|
224
|
+
|
|
225
|
+
{/* Description field */}
|
|
226
|
+
<div className="ermis-channel-info__edit-field">
|
|
227
|
+
<label className="ermis-channel-info__edit-label">{descriptionLabel}</label>
|
|
228
|
+
<textarea
|
|
229
|
+
className="ermis-channel-info__edit-textarea"
|
|
230
|
+
value={description}
|
|
231
|
+
onChange={(e) => setDescription(e.target.value)}
|
|
232
|
+
placeholder={descriptionPlaceholder}
|
|
233
|
+
disabled={isSaving}
|
|
234
|
+
rows={3}
|
|
235
|
+
maxLength={500}
|
|
236
|
+
/>
|
|
237
|
+
</div>
|
|
238
|
+
|
|
239
|
+
{/* Public toggle — only for team channels */}
|
|
240
|
+
{isTeamChannel && (
|
|
241
|
+
<div className="ermis-channel-info__edit-field ermis-channel-info__edit-field--toggle">
|
|
242
|
+
<label className="ermis-channel-info__edit-label">{publicLabel}</label>
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
role="switch"
|
|
246
|
+
aria-checked={isPublic}
|
|
247
|
+
className={`ermis-channel-info__edit-toggle ${isPublic ? 'ermis-channel-info__edit-toggle--on' : ''}`}
|
|
248
|
+
onClick={() => setIsPublic(v => !v)}
|
|
249
|
+
disabled={isSaving}
|
|
250
|
+
>
|
|
251
|
+
<span className="ermis-channel-info__edit-toggle-thumb" />
|
|
252
|
+
</button>
|
|
253
|
+
</div>
|
|
254
|
+
)}
|
|
255
|
+
|
|
256
|
+
{/* Error */}
|
|
257
|
+
{error && (
|
|
258
|
+
<div className="ermis-channel-info__edit-error">
|
|
259
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
260
|
+
<circle cx="12" cy="12" r="10" />
|
|
261
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
262
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
263
|
+
</svg>
|
|
264
|
+
{error}
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
</Modal>
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
EditChannelModal.displayName = 'EditChannelModal';
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { formatFileSize, formatRelativeDate, getDisplayName } from '../../utils';
|
|
3
|
+
import { getFileIcon } from './utils';
|
|
4
|
+
import type { AttachmentItem } from '../../types';
|
|
5
|
+
|
|
6
|
+
export const FileListItem: React.FC<{
|
|
7
|
+
item: AttachmentItem;
|
|
8
|
+
onClick: (url: string) => void;
|
|
9
|
+
}> = React.memo(({ item, onClick }) => {
|
|
10
|
+
const displayName = getDisplayName(item.file_name);
|
|
11
|
+
const ext = item.file_name.split('.').pop()?.toUpperCase() || 'FILE';
|
|
12
|
+
|
|
13
|
+
return (
|
|
14
|
+
<div
|
|
15
|
+
className="ermis-channel-info__file-item"
|
|
16
|
+
onClick={() => onClick(item.url)}
|
|
17
|
+
>
|
|
18
|
+
<div className="ermis-channel-info__file-icon">
|
|
19
|
+
{getFileIcon(item.content_type, item.file_name)}
|
|
20
|
+
<span className="ermis-channel-info__file-ext">{ext}</span>
|
|
21
|
+
</div>
|
|
22
|
+
<div className="ermis-channel-info__file-info">
|
|
23
|
+
<span className="ermis-channel-info__file-name" title={item.file_name}>
|
|
24
|
+
{displayName}
|
|
25
|
+
</span>
|
|
26
|
+
<div className="ermis-channel-info__file-meta">
|
|
27
|
+
<span>{formatFileSize(item.content_length)}</span>
|
|
28
|
+
<span className="ermis-channel-info__file-meta-dot">·</span>
|
|
29
|
+
<span>{formatRelativeDate(item.created_at)}</span>
|
|
30
|
+
</div>
|
|
31
|
+
</div>
|
|
32
|
+
<button
|
|
33
|
+
className="ermis-channel-info__file-download"
|
|
34
|
+
onClick={(e) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
onClick(item.url);
|
|
37
|
+
}}
|
|
38
|
+
aria-label="Download"
|
|
39
|
+
>
|
|
40
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
|
42
|
+
<polyline points="7 10 12 15 17 10" />
|
|
43
|
+
<line x1="12" y1="15" x2="12" y2="3" />
|
|
44
|
+
</svg>
|
|
45
|
+
</button>
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}, (prev, next) => prev.item.id === next.item.id);
|
|
49
|
+
(FileListItem as any).displayName = 'FileListItem';
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { formatRelativeDate, extractDomain, isImagePreloaded, preloadImage } from '../../utils';
|
|
3
|
+
import type { AttachmentItem } from '../../types';
|
|
4
|
+
|
|
5
|
+
export const LinkListItem: React.FC<{ item: AttachmentItem }> = React.memo(({ item }) => {
|
|
6
|
+
const displayUrl = item.og_scrape_url || item.title_link || item.url;
|
|
7
|
+
const domain = extractDomain(displayUrl);
|
|
8
|
+
|
|
9
|
+
// Preload link preview image if available
|
|
10
|
+
const imgSrc = item.image_url;
|
|
11
|
+
const alreadyCached = imgSrc ? isImagePreloaded(imgSrc) : true;
|
|
12
|
+
const [imgLoaded, setImgLoaded] = useState(alreadyCached);
|
|
13
|
+
const imgRef = React.useRef<HTMLImageElement>(null);
|
|
14
|
+
|
|
15
|
+
useMemo(() => { if (imgSrc) preloadImage(imgSrc); }, [imgSrc]);
|
|
16
|
+
|
|
17
|
+
React.useEffect(() => {
|
|
18
|
+
if (!imgLoaded && imgRef.current?.complete) {
|
|
19
|
+
setImgLoaded(true);
|
|
20
|
+
}
|
|
21
|
+
}, [imgLoaded, imgSrc]);
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<a
|
|
25
|
+
className="ermis-channel-info__link-item"
|
|
26
|
+
href={displayUrl}
|
|
27
|
+
target="_blank"
|
|
28
|
+
rel="noopener noreferrer"
|
|
29
|
+
>
|
|
30
|
+
<div className="ermis-channel-info__link-icon">
|
|
31
|
+
{imgSrc ? (
|
|
32
|
+
<div style={{ width: '100%', height: '100%', position: 'relative' }}>
|
|
33
|
+
{!imgLoaded && <div className="ermis-channel-info__media-shimmer" style={{ borderRadius: '8px' }} />}
|
|
34
|
+
<img
|
|
35
|
+
ref={imgRef}
|
|
36
|
+
src={imgSrc}
|
|
37
|
+
alt=""
|
|
38
|
+
className="ermis-channel-info__link-preview-img"
|
|
39
|
+
loading="lazy"
|
|
40
|
+
onLoad={() => setImgLoaded(true)}
|
|
41
|
+
style={{ opacity: imgLoaded ? 1 : 0, transition: 'opacity 0.3s ease-in-out' }}
|
|
42
|
+
/>
|
|
43
|
+
</div>
|
|
44
|
+
) : (
|
|
45
|
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
46
|
+
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
|
|
47
|
+
<polyline points="15 3 21 3 21 9" />
|
|
48
|
+
<line x1="10" y1="14" x2="21" y2="3" />
|
|
49
|
+
</svg>
|
|
50
|
+
)}
|
|
51
|
+
</div>
|
|
52
|
+
<div className="ermis-channel-info__link-content">
|
|
53
|
+
<span className="ermis-channel-info__link-title">
|
|
54
|
+
{item.title || item.file_name || domain}
|
|
55
|
+
</span>
|
|
56
|
+
<span className="ermis-channel-info__link-domain">{domain}</span>
|
|
57
|
+
</div>
|
|
58
|
+
<span className="ermis-channel-info__link-date">{formatRelativeDate(item.created_at)}</span>
|
|
59
|
+
</a>
|
|
60
|
+
);
|
|
61
|
+
}, (prev, next) => prev.item.id === next.item.id);
|
|
62
|
+
(LinkListItem as any).displayName = 'LinkListItem';
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React, { useState, useMemo } from 'react';
|
|
2
|
+
import { preloadImage, isImagePreloaded } from '../../utils';
|
|
3
|
+
import type { AttachmentItem } from '../../types';
|
|
4
|
+
|
|
5
|
+
export const MediaGridItem: React.FC<{
|
|
6
|
+
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);
|
|
13
|
+
|
|
14
|
+
// Trigger background preload (no-op if already cached)
|
|
15
|
+
useMemo(() => { preloadImage(src); }, [src]);
|
|
16
|
+
|
|
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);
|
|
21
|
+
}
|
|
22
|
+
}, [loaded, src]);
|
|
23
|
+
|
|
24
|
+
const isVideo = item.attachment_type === 'video';
|
|
25
|
+
|
|
26
|
+
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
|
+
/>
|
|
53
|
+
)}
|
|
54
|
+
<div className="ermis-channel-info__media-play-icon">
|
|
55
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
|
56
|
+
<polygon points="5 3 19 12 5 21 5 3" />
|
|
57
|
+
</svg>
|
|
58
|
+
</div>
|
|
59
|
+
</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
|
+
/>
|
|
69
|
+
)}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}, (prev, next) => prev.item.id === next.item.id);
|
|
73
|
+
(MediaGridItem as any).displayName = 'MediaGridItem';
|
|
74
|
+
|
|
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
|
+
});
|
|
90
|
+
(MediaRow as any).displayName = 'MediaRow';
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import { Dropdown } from '../Dropdown';
|
|
3
|
+
import type { ChannelInfoMemberItemProps } from '../../types';
|
|
4
|
+
|
|
5
|
+
export const MemberListItem = React.memo(({
|
|
6
|
+
member, AvatarComponent,
|
|
7
|
+
onRemove, canRemove,
|
|
8
|
+
onBan, canBan,
|
|
9
|
+
onUnban, canUnban,
|
|
10
|
+
onPromote, canPromote,
|
|
11
|
+
onDemote, canDemote
|
|
12
|
+
}: ChannelInfoMemberItemProps) => {
|
|
13
|
+
const [anchorRect, setAnchorRect] = useState<DOMRect | null>(null);
|
|
14
|
+
const isOpen = anchorRect !== null;
|
|
15
|
+
|
|
16
|
+
if (!member) return null;
|
|
17
|
+
const role = member.channel_role || 'member';
|
|
18
|
+
const hasActions = canRemove || canBan || canUnban || canPromote || canDemote;
|
|
19
|
+
|
|
20
|
+
return (
|
|
21
|
+
<div className="ermis-channel-info__member-item">
|
|
22
|
+
<AvatarComponent image={member.user?.avatar} name={member.user?.name || member.user?.id} size={36} />
|
|
23
|
+
<div className="ermis-channel-info__member-info">
|
|
24
|
+
<span className="ermis-channel-info__member-name">{member.user?.name || member.user?.id}</span>
|
|
25
|
+
<span className={`ermis-channel-info__member-role ermis-channel-info__member-role--${role.toLowerCase()}`}>
|
|
26
|
+
{role.charAt(0).toUpperCase() + role.slice(1)}
|
|
27
|
+
</span>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
{hasActions && (
|
|
31
|
+
<>
|
|
32
|
+
<button
|
|
33
|
+
className="ermis-channel-info__member-actions-btn"
|
|
34
|
+
onClick={(e) => {
|
|
35
|
+
e.stopPropagation();
|
|
36
|
+
setAnchorRect(e.currentTarget.getBoundingClientRect());
|
|
37
|
+
}}
|
|
38
|
+
aria-label="Member actions"
|
|
39
|
+
>
|
|
40
|
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<circle cx="12" cy="12" r="1" />
|
|
42
|
+
<circle cx="12" cy="5" r="1" />
|
|
43
|
+
<circle cx="12" cy="19" r="1" />
|
|
44
|
+
</svg>
|
|
45
|
+
</button>
|
|
46
|
+
|
|
47
|
+
<Dropdown
|
|
48
|
+
isOpen={isOpen}
|
|
49
|
+
anchorRect={anchorRect}
|
|
50
|
+
onClose={() => setAnchorRect(null)}
|
|
51
|
+
align="right"
|
|
52
|
+
>
|
|
53
|
+
<div className="ermis-dropdown__menu">
|
|
54
|
+
{canPromote && onPromote && (
|
|
55
|
+
<button className="ermis-dropdown__item" onClick={() => { onPromote(member.user?.id || member.user_id); setAnchorRect(null); }}>Promote to Moder</button>
|
|
56
|
+
)}
|
|
57
|
+
{canDemote && onDemote && (
|
|
58
|
+
<button className="ermis-dropdown__item" onClick={() => { onDemote(member.user?.id || member.user_id); setAnchorRect(null); }}>Demote to Member</button>
|
|
59
|
+
)}
|
|
60
|
+
{canBan && onBan && (
|
|
61
|
+
<button className="ermis-dropdown__item ermis-dropdown__item--danger" onClick={() => { onBan(member.user?.id || member.user_id); setAnchorRect(null); }}>Ban Member</button>
|
|
62
|
+
)}
|
|
63
|
+
{canUnban && onUnban && (
|
|
64
|
+
<button className="ermis-dropdown__item" onClick={() => { onUnban(member.user?.id || member.user_id); setAnchorRect(null); }}>Unban Member</button>
|
|
65
|
+
)}
|
|
66
|
+
{canRemove && onRemove && (
|
|
67
|
+
<button className="ermis-dropdown__item ermis-dropdown__item--danger" onClick={() => { onRemove(member.user?.id || member.user_id); setAnchorRect(null); }}>Remove from Channel</button>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
</Dropdown>
|
|
71
|
+
</>
|
|
72
|
+
)}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}, (prev, next) => {
|
|
76
|
+
return prev.member?.user_id === next.member?.user_id &&
|
|
77
|
+
prev.member?.channel_role === next.member?.channel_role &&
|
|
78
|
+
prev.member?.banned === next.member?.banned &&
|
|
79
|
+
prev.canRemove === next.canRemove &&
|
|
80
|
+
prev.canBan === next.canBan &&
|
|
81
|
+
prev.canUnban === next.canUnban &&
|
|
82
|
+
prev.canPromote === next.canPromote &&
|
|
83
|
+
prev.canDemote === next.canDemote;
|
|
84
|
+
});
|
|
85
|
+
(MemberListItem as any).displayName = 'MemberListItem';
|