@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.
Files changed (88) hide show
  1. package/dist/index.cjs +6593 -0
  2. package/dist/index.cjs.map +1 -0
  3. package/dist/index.css +3375 -0
  4. package/dist/index.css.map +1 -0
  5. package/dist/index.d.mts +1138 -0
  6. package/dist/index.d.ts +1138 -0
  7. package/dist/index.mjs +6500 -0
  8. package/dist/index.mjs.map +1 -0
  9. package/package.json +42 -0
  10. package/src/components/Avatar.tsx +102 -0
  11. package/src/components/Channel.tsx +77 -0
  12. package/src/components/ChannelHeader.tsx +85 -0
  13. package/src/components/ChannelInfo/AddMemberModal.tsx +204 -0
  14. package/src/components/ChannelInfo/ChannelInfo.tsx +455 -0
  15. package/src/components/ChannelInfo/ChannelInfoTabs.tsx +282 -0
  16. package/src/components/ChannelInfo/ChannelSettingsPanel.tsx +479 -0
  17. package/src/components/ChannelInfo/EditChannelModal.tsx +272 -0
  18. package/src/components/ChannelInfo/FileListItem.tsx +49 -0
  19. package/src/components/ChannelInfo/LinkListItem.tsx +62 -0
  20. package/src/components/ChannelInfo/MediaGridItem.tsx +90 -0
  21. package/src/components/ChannelInfo/MemberListItem.tsx +85 -0
  22. package/src/components/ChannelInfo/MessageSearchPanel.tsx +333 -0
  23. package/src/components/ChannelInfo/States.tsx +36 -0
  24. package/src/components/ChannelInfo/index.ts +10 -0
  25. package/src/components/ChannelInfo/utils.tsx +49 -0
  26. package/src/components/ChannelList.tsx +395 -0
  27. package/src/components/Dropdown.tsx +120 -0
  28. package/src/components/EditPreview.tsx +102 -0
  29. package/src/components/FilesPreview.tsx +108 -0
  30. package/src/components/ForwardMessageModal.tsx +234 -0
  31. package/src/components/MentionSuggestions.tsx +59 -0
  32. package/src/components/MessageActionsBox.tsx +186 -0
  33. package/src/components/MessageInput.tsx +513 -0
  34. package/src/components/MessageInputDefaults.tsx +50 -0
  35. package/src/components/MessageItem.tsx +218 -0
  36. package/src/components/MessageQuickReactions.tsx +73 -0
  37. package/src/components/MessageReactions.tsx +59 -0
  38. package/src/components/MessageRenderers.tsx +565 -0
  39. package/src/components/Modal.tsx +58 -0
  40. package/src/components/Panel.tsx +64 -0
  41. package/src/components/PinnedMessages.tsx +165 -0
  42. package/src/components/QuotedMessagePreview.tsx +55 -0
  43. package/src/components/ReadReceipts.tsx +80 -0
  44. package/src/components/ReplyPreview.tsx +98 -0
  45. package/src/components/TypingIndicator.tsx +57 -0
  46. package/src/components/VirtualMessageList.tsx +425 -0
  47. package/src/context/ChatProvider.tsx +73 -0
  48. package/src/hooks/useBannedState.ts +48 -0
  49. package/src/hooks/useBlockedState.ts +55 -0
  50. package/src/hooks/useChannel.ts +18 -0
  51. package/src/hooks/useChannelCapabilities.ts +42 -0
  52. package/src/hooks/useChannelData.ts +55 -0
  53. package/src/hooks/useChannelListUpdates.ts +224 -0
  54. package/src/hooks/useChannelMessages.ts +159 -0
  55. package/src/hooks/useChannelRowUpdates.ts +78 -0
  56. package/src/hooks/useChatClient.ts +11 -0
  57. package/src/hooks/useEmojiPicker.ts +53 -0
  58. package/src/hooks/useFileUpload.ts +128 -0
  59. package/src/hooks/useLoadMessages.ts +178 -0
  60. package/src/hooks/useMentions.ts +287 -0
  61. package/src/hooks/useMessageActions.ts +87 -0
  62. package/src/hooks/useMessageSend.ts +164 -0
  63. package/src/hooks/usePendingState.ts +63 -0
  64. package/src/hooks/useScrollToMessage.ts +155 -0
  65. package/src/hooks/useTypingIndicator.ts +86 -0
  66. package/src/index.ts +129 -0
  67. package/src/styles/_add-member-modal.css +122 -0
  68. package/src/styles/_base.css +32 -0
  69. package/src/styles/_channel-info.css +941 -0
  70. package/src/styles/_channel-list.css +217 -0
  71. package/src/styles/_dropdown.css +69 -0
  72. package/src/styles/_forward-modal.css +191 -0
  73. package/src/styles/_mentions.css +102 -0
  74. package/src/styles/_message-actions.css +61 -0
  75. package/src/styles/_message-bubble.css +656 -0
  76. package/src/styles/_message-input.css +389 -0
  77. package/src/styles/_message-list.css +416 -0
  78. package/src/styles/_message-quick-reactions.css +62 -0
  79. package/src/styles/_message-reactions.css +67 -0
  80. package/src/styles/_modal.css +113 -0
  81. package/src/styles/_panel.css +69 -0
  82. package/src/styles/_pinned-messages.css +140 -0
  83. package/src/styles/_search-panel.css +219 -0
  84. package/src/styles/_tokens.css +92 -0
  85. package/src/styles/_typing-indicator.css +59 -0
  86. package/src/styles/index.css +24 -0
  87. package/src/types.ts +955 -0
  88. 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';