@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,108 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { isHeicFile } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { FilesPreviewProps } from '../types';
|
|
4
|
+
|
|
5
|
+
export type { FilePreviewItem, FilesPreviewProps } from '../types';
|
|
6
|
+
/**
|
|
7
|
+
* Format file size into human-readable string.
|
|
8
|
+
*/
|
|
9
|
+
function formatFileSize(bytes: number): string {
|
|
10
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
11
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
12
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get a display icon for non-previewable file types.
|
|
17
|
+
*/
|
|
18
|
+
function getFileIcon(mimeType: string): string {
|
|
19
|
+
if (mimeType.startsWith('audio/')) return '🎵';
|
|
20
|
+
if (mimeType.startsWith('video/')) return '🎬';
|
|
21
|
+
if (mimeType.includes('pdf')) return '📄';
|
|
22
|
+
if (mimeType.includes('zip') || mimeType.includes('rar') || mimeType.includes('tar')) return '📦';
|
|
23
|
+
return '📎';
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* FilesPreview — renders selected files with thumbnails and remove buttons.
|
|
28
|
+
* Shown above the text input area in MessageInput.
|
|
29
|
+
*/
|
|
30
|
+
export const FilesPreview: React.FC<FilesPreviewProps> = React.memo(({ files, onRemove }) => {
|
|
31
|
+
if (files.length === 0) return null;
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<div className="ermis-files-preview">
|
|
35
|
+
{files.map((item) => {
|
|
36
|
+
const fileType = item.file?.type || item.originalAttachment?.mime_type || '';
|
|
37
|
+
const fileName = item.file?.name || item.originalAttachment?.title || 'Unknown file';
|
|
38
|
+
const fileSize = item.file?.size || item.originalAttachment?.file_size || 0;
|
|
39
|
+
|
|
40
|
+
const isHeic = item.file ? isHeicFile(item.file) : (fileType === 'image/heic' || fileType === 'image/heif');
|
|
41
|
+
const isImage = fileType.startsWith('image/') && !isHeic;
|
|
42
|
+
const isVideo = fileType.startsWith('video/');
|
|
43
|
+
const isUploading = item.status === 'uploading';
|
|
44
|
+
const hasError = item.status === 'error';
|
|
45
|
+
|
|
46
|
+
const previewUrl = item.previewUrl || item.originalAttachment?.image_url || item.originalAttachment?.asset_url;
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div
|
|
50
|
+
key={item.id}
|
|
51
|
+
className={`ermis-files-preview__item${hasError ? ' ermis-files-preview__item--error' : ''}`}
|
|
52
|
+
>
|
|
53
|
+
{/* Remove button */}
|
|
54
|
+
<button
|
|
55
|
+
className="ermis-files-preview__remove"
|
|
56
|
+
onClick={() => onRemove(item.id)}
|
|
57
|
+
aria-label="Remove file"
|
|
58
|
+
type="button"
|
|
59
|
+
>
|
|
60
|
+
✕
|
|
61
|
+
</button>
|
|
62
|
+
|
|
63
|
+
{/* Preview content */}
|
|
64
|
+
{isImage && previewUrl ? (
|
|
65
|
+
<img
|
|
66
|
+
className="ermis-files-preview__thumb"
|
|
67
|
+
src={previewUrl}
|
|
68
|
+
alt={fileName}
|
|
69
|
+
/>
|
|
70
|
+
) : isVideo && previewUrl ? (
|
|
71
|
+
<video
|
|
72
|
+
className="ermis-files-preview__thumb"
|
|
73
|
+
src={previewUrl}
|
|
74
|
+
muted
|
|
75
|
+
/>
|
|
76
|
+
) : (
|
|
77
|
+
<div className="ermis-files-preview__file-icon">
|
|
78
|
+
<span>{getFileIcon(fileType)}</span>
|
|
79
|
+
</div>
|
|
80
|
+
)}
|
|
81
|
+
|
|
82
|
+
{/* File info */}
|
|
83
|
+
<div className="ermis-files-preview__info">
|
|
84
|
+
<span className="ermis-files-preview__name">{fileName}</span>
|
|
85
|
+
<span className="ermis-files-preview__size">{formatFileSize(Number(fileSize))}</span>
|
|
86
|
+
</div>
|
|
87
|
+
|
|
88
|
+
{/* Upload status overlay */}
|
|
89
|
+
{isUploading && (
|
|
90
|
+
<div className="ermis-files-preview__uploading">
|
|
91
|
+
<span className="ermis-files-preview__spinner" />
|
|
92
|
+
</div>
|
|
93
|
+
)}
|
|
94
|
+
|
|
95
|
+
{/* Error overlay */}
|
|
96
|
+
{hasError && (
|
|
97
|
+
<div className="ermis-files-preview__error-badge" title={item.error}>
|
|
98
|
+
⚠
|
|
99
|
+
</div>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
})}
|
|
104
|
+
</div>
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
FilesPreview.displayName = 'FilesPreview';
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';
|
|
2
|
+
import { createForwardMessagePayload } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import { Avatar } from './Avatar';
|
|
6
|
+
import { Modal } from './Modal';
|
|
7
|
+
import type { ForwardMessageModalProps, ForwardChannelItemProps, AvatarProps } from '../types';
|
|
8
|
+
|
|
9
|
+
export type { ForwardMessageModalProps, ForwardChannelItemProps } from '../types';
|
|
10
|
+
|
|
11
|
+
/* ----------------------------------------------------------
|
|
12
|
+
Default channel item row with checkbox
|
|
13
|
+
---------------------------------------------------------- */
|
|
14
|
+
const DefaultForwardChannelItem: React.FC<ForwardChannelItemProps> = React.memo(({
|
|
15
|
+
channel,
|
|
16
|
+
selected,
|
|
17
|
+
onToggle,
|
|
18
|
+
AvatarComponent,
|
|
19
|
+
}) => {
|
|
20
|
+
const name = (channel.data?.name || channel.cid) as string;
|
|
21
|
+
const rawImage = channel.data?.image as string | undefined;
|
|
22
|
+
// Parse emoji:// format → extract just the emoji for avatar fallback
|
|
23
|
+
const isEmoji = rawImage?.startsWith('emoji://');
|
|
24
|
+
const image = isEmoji ? undefined : rawImage;
|
|
25
|
+
const emojiIcon = isEmoji ? rawImage!.replace('emoji://', '') : undefined;
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<div
|
|
29
|
+
className={`ermis-forward-modal__channel-item ${selected ? 'ermis-forward-modal__channel-item--selected' : ''}`}
|
|
30
|
+
onClick={() => onToggle(channel)}
|
|
31
|
+
>
|
|
32
|
+
{emojiIcon ? (
|
|
33
|
+
<span className="ermis-forward-modal__channel-emoji" style={{ fontSize: 24, width: 36, textAlign: 'center' }}>{emojiIcon}</span>
|
|
34
|
+
) : (
|
|
35
|
+
<AvatarComponent image={image} name={name} size={36} />
|
|
36
|
+
)}
|
|
37
|
+
<span className="ermis-forward-modal__channel-name">{name}</span>
|
|
38
|
+
<div className={`ermis-forward-modal__checkbox ${selected ? 'ermis-forward-modal__checkbox--checked' : ''}`}>
|
|
39
|
+
{selected && (
|
|
40
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
41
|
+
<polyline points="20 6 9 17 4 12" />
|
|
42
|
+
</svg>
|
|
43
|
+
)}
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
});
|
|
48
|
+
DefaultForwardChannelItem.displayName = 'DefaultForwardChannelItem';
|
|
49
|
+
|
|
50
|
+
/* ----------------------------------------------------------
|
|
51
|
+
ForwardMessageModal
|
|
52
|
+
---------------------------------------------------------- */
|
|
53
|
+
export const ForwardMessageModal: React.FC<ForwardMessageModalProps> = ({
|
|
54
|
+
message,
|
|
55
|
+
onDismiss,
|
|
56
|
+
ChannelItemComponent = DefaultForwardChannelItem,
|
|
57
|
+
SearchInputComponent,
|
|
58
|
+
}) => {
|
|
59
|
+
const { client, activeChannel } = useChatClient();
|
|
60
|
+
const [selectedChannels, setSelectedChannels] = useState<Set<string>>(new Set());
|
|
61
|
+
const [search, setSearch] = useState('');
|
|
62
|
+
const [sending, setSending] = useState(false);
|
|
63
|
+
const [results, setResults] = useState<{ success: string[]; failed: string[] } | null>(null);
|
|
64
|
+
const backdropRef = useRef<HTMLDivElement>(null);
|
|
65
|
+
|
|
66
|
+
/* ---------- Get channels from client state (exclude topics) ---------- */
|
|
67
|
+
const channels = useMemo(() => {
|
|
68
|
+
return (Object.values(client.activeChannels) as Channel[]).filter(
|
|
69
|
+
(ch) => ch.type !== 'topic',
|
|
70
|
+
);
|
|
71
|
+
}, [client.activeChannels]);
|
|
72
|
+
|
|
73
|
+
/* ---------- Filter by search ---------- */
|
|
74
|
+
const filteredChannels = useMemo(() => {
|
|
75
|
+
if (!search.trim()) return channels;
|
|
76
|
+
const q = search.toLowerCase();
|
|
77
|
+
return channels.filter((ch) => {
|
|
78
|
+
const name = ((ch.data?.name || ch.cid) as string).toLowerCase();
|
|
79
|
+
return name.includes(q);
|
|
80
|
+
});
|
|
81
|
+
}, [channels, search]);
|
|
82
|
+
|
|
83
|
+
/* ---------- Toggle selection ---------- */
|
|
84
|
+
const toggleChannel = useCallback((channel: Channel) => {
|
|
85
|
+
setSelectedChannels((prev) => {
|
|
86
|
+
const next = new Set(prev);
|
|
87
|
+
if (next.has(channel.cid)) {
|
|
88
|
+
next.delete(channel.cid);
|
|
89
|
+
} else {
|
|
90
|
+
next.add(channel.cid);
|
|
91
|
+
}
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}, []);
|
|
95
|
+
|
|
96
|
+
/* ---------- Send forward ---------- */
|
|
97
|
+
const handleSend = useCallback(async () => {
|
|
98
|
+
if (!activeChannel || selectedChannels.size === 0 || sending) return;
|
|
99
|
+
setSending(true);
|
|
100
|
+
const success: string[] = [];
|
|
101
|
+
const failed: string[] = [];
|
|
102
|
+
|
|
103
|
+
for (const cid of selectedChannels) {
|
|
104
|
+
const targetChannel = channels.find((c) => c.cid === cid);
|
|
105
|
+
if (!targetChannel) continue;
|
|
106
|
+
try {
|
|
107
|
+
const forwardPayload = createForwardMessagePayload(
|
|
108
|
+
message,
|
|
109
|
+
targetChannel.cid as string,
|
|
110
|
+
activeChannel.cid as string,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
await activeChannel.forwardMessage(forwardPayload, {
|
|
114
|
+
type: targetChannel.type,
|
|
115
|
+
channelID: targetChannel.id!,
|
|
116
|
+
});
|
|
117
|
+
success.push((targetChannel.data?.name || targetChannel.cid) as string);
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.error(`Failed to forward to ${cid}`, err);
|
|
120
|
+
failed.push((targetChannel.data?.name || targetChannel.cid) as string);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
setResults({ success, failed });
|
|
125
|
+
setSending(false);
|
|
126
|
+
|
|
127
|
+
// Auto-close after success (short delay)
|
|
128
|
+
if (failed.length === 0) {
|
|
129
|
+
setTimeout(() => onDismiss(), 1200);
|
|
130
|
+
}
|
|
131
|
+
}, [activeChannel, selectedChannels, channels, message, sending, onDismiss]);
|
|
132
|
+
|
|
133
|
+
/* ---------- Keyboard / backdrop close ---------- */
|
|
134
|
+
useEffect(() => {
|
|
135
|
+
const handleKey = (e: KeyboardEvent) => {
|
|
136
|
+
if (e.key === 'Escape') onDismiss();
|
|
137
|
+
};
|
|
138
|
+
document.addEventListener('keydown', handleKey);
|
|
139
|
+
return () => document.removeEventListener('keydown', handleKey);
|
|
140
|
+
}, [onDismiss]);
|
|
141
|
+
|
|
142
|
+
const handleBackdropClick = useCallback((e: React.MouseEvent) => {
|
|
143
|
+
if (e.target === backdropRef.current) onDismiss();
|
|
144
|
+
}, [onDismiss]);
|
|
145
|
+
|
|
146
|
+
/* ---------- Message preview ---------- */
|
|
147
|
+
const previewText = message.text
|
|
148
|
+
? (message.text.length > 120 ? message.text.slice(0, 120) + '…' : message.text)
|
|
149
|
+
: '';
|
|
150
|
+
const attachmentCount = message.attachments?.length ?? 0;
|
|
151
|
+
|
|
152
|
+
const footer = (
|
|
153
|
+
<>
|
|
154
|
+
<button className="ermis-forward-modal__btn ermis-forward-modal__btn--cancel" onClick={onDismiss}>
|
|
155
|
+
Cancel
|
|
156
|
+
</button>
|
|
157
|
+
<button
|
|
158
|
+
className="ermis-forward-modal__btn ermis-forward-modal__btn--send"
|
|
159
|
+
onClick={handleSend}
|
|
160
|
+
disabled={selectedChannels.size === 0 || sending || results !== null}
|
|
161
|
+
>
|
|
162
|
+
{sending ? 'Sending…' : `Forward${selectedChannels.size > 0 ? ` (${selectedChannels.size})` : ''}`}
|
|
163
|
+
</button>
|
|
164
|
+
</>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<Modal isOpen onClose={onDismiss} title="Forward Message" footer={footer}>
|
|
169
|
+
{/* Message preview */}
|
|
170
|
+
<div className="ermis-forward-modal__preview">
|
|
171
|
+
<div className="ermis-forward-modal__preview-sender">
|
|
172
|
+
{message.user?.name || message.user_id || 'Unknown'}
|
|
173
|
+
</div>
|
|
174
|
+
{previewText && (
|
|
175
|
+
<div className="ermis-forward-modal__preview-text">{previewText}</div>
|
|
176
|
+
)}
|
|
177
|
+
{attachmentCount > 0 && (
|
|
178
|
+
<div className="ermis-forward-modal__preview-attachments">
|
|
179
|
+
📎 {attachmentCount} attachment{attachmentCount > 1 ? 's' : ''}
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
|
|
184
|
+
{/* Search */}
|
|
185
|
+
<div className="ermis-forward-modal__search-wrapper">
|
|
186
|
+
{SearchInputComponent ? (
|
|
187
|
+
<SearchInputComponent value={search} onChange={setSearch} />
|
|
188
|
+
) : (
|
|
189
|
+
<input
|
|
190
|
+
className="ermis-forward-modal__search"
|
|
191
|
+
type="text"
|
|
192
|
+
placeholder="Search channels…"
|
|
193
|
+
value={search}
|
|
194
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
195
|
+
autoFocus
|
|
196
|
+
/>
|
|
197
|
+
)}
|
|
198
|
+
</div>
|
|
199
|
+
|
|
200
|
+
{/* Channel list */}
|
|
201
|
+
<div className="ermis-forward-modal__channel-list">
|
|
202
|
+
{filteredChannels.length === 0 ? (
|
|
203
|
+
<div className="ermis-forward-modal__empty">No channels found</div>
|
|
204
|
+
) : (
|
|
205
|
+
filteredChannels.map((ch) => (
|
|
206
|
+
<ChannelItemComponent
|
|
207
|
+
key={ch.cid}
|
|
208
|
+
channel={ch}
|
|
209
|
+
selected={selectedChannels.has(ch.cid)}
|
|
210
|
+
onToggle={toggleChannel}
|
|
211
|
+
AvatarComponent={Avatar}
|
|
212
|
+
/>
|
|
213
|
+
))
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
|
|
217
|
+
{/* Results feedback */}
|
|
218
|
+
{results && (
|
|
219
|
+
<div className="ermis-forward-modal__results">
|
|
220
|
+
{results.success.length > 0 && (
|
|
221
|
+
<div className="ermis-forward-modal__results-success">
|
|
222
|
+
✓ Sent to {results.success.join(', ')}
|
|
223
|
+
</div>
|
|
224
|
+
)}
|
|
225
|
+
{results.failed.length > 0 && (
|
|
226
|
+
<div className="ermis-forward-modal__results-failed">
|
|
227
|
+
✗ Failed: {results.failed.join(', ')}
|
|
228
|
+
</div>
|
|
229
|
+
)}
|
|
230
|
+
</div>
|
|
231
|
+
)}
|
|
232
|
+
</Modal>
|
|
233
|
+
);
|
|
234
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import React, { useEffect, useRef } from 'react';
|
|
2
|
+
import { VList, VListHandle } from 'virtua';
|
|
3
|
+
import { Avatar } from './Avatar';
|
|
4
|
+
import type { MentionSuggestionsProps } from '../types';
|
|
5
|
+
|
|
6
|
+
export type { MentionSuggestionsProps } from '../types';
|
|
7
|
+
|
|
8
|
+
// Estimated item height
|
|
9
|
+
const ITEM_HEIGHT = 42;
|
|
10
|
+
|
|
11
|
+
export const MentionSuggestions: React.FC<MentionSuggestionsProps> = React.memo(({
|
|
12
|
+
members,
|
|
13
|
+
highlightIndex,
|
|
14
|
+
onSelect,
|
|
15
|
+
}) => {
|
|
16
|
+
const listRef = useRef<VListHandle>(null);
|
|
17
|
+
|
|
18
|
+
// Auto-scroll highlighted item into view
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
// VList uses scrollToIndex
|
|
21
|
+
listRef.current?.scrollToIndex(highlightIndex);
|
|
22
|
+
}, [highlightIndex]);
|
|
23
|
+
|
|
24
|
+
if (members.length === 0) return null;
|
|
25
|
+
|
|
26
|
+
// Calculate dynamic height based on item count, cap at 200px
|
|
27
|
+
const listHeight = Math.min(members.length * ITEM_HEIGHT, 200);
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="ermis-mention-suggestions" style={{ overflow: 'hidden' }}>
|
|
31
|
+
<VList ref={listRef} style={{ height: listHeight }}>
|
|
32
|
+
{members.map((member, index) => (
|
|
33
|
+
<div
|
|
34
|
+
key={member.id}
|
|
35
|
+
className={`ermis-mention-suggestions__item${
|
|
36
|
+
index === highlightIndex ? ' ermis-mention-suggestions__item--highlighted' : ''
|
|
37
|
+
}`}
|
|
38
|
+
onMouseDown={(e) => {
|
|
39
|
+
// Use mousedown (not click) to fire before blur
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
onSelect(member);
|
|
42
|
+
}}
|
|
43
|
+
>
|
|
44
|
+
{member.id === '__all__' ? (
|
|
45
|
+
<div className="ermis-mention-suggestions__all-icon">@</div>
|
|
46
|
+
) : (
|
|
47
|
+
<Avatar image={member.avatar} name={member.name} size={24} />
|
|
48
|
+
)}
|
|
49
|
+
<span className="ermis-mention-suggestions__name">
|
|
50
|
+
{member.id === '__all__' ? 'all' : member.name}
|
|
51
|
+
</span>
|
|
52
|
+
</div>
|
|
53
|
+
))}
|
|
54
|
+
</VList>
|
|
55
|
+
</div>
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
MentionSuggestions.displayName = 'MentionSuggestions';
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { useMessageActions } from '../hooks/useMessageActions';
|
|
4
|
+
import { useChatClient } from '../hooks/useChatClient';
|
|
5
|
+
import type { MessageActionsBoxProps } from '../types';
|
|
6
|
+
import { Dropdown, closeAllDropdowns } from './Dropdown';
|
|
7
|
+
|
|
8
|
+
// Aliased for backward compatibility
|
|
9
|
+
export const closeAllActionBoxes = closeAllDropdowns;
|
|
10
|
+
|
|
11
|
+
export const MessageActionsBox: React.FC<MessageActionsBoxProps> = ({
|
|
12
|
+
message,
|
|
13
|
+
isOwnMessage,
|
|
14
|
+
onReply: onReplyProp,
|
|
15
|
+
onForward,
|
|
16
|
+
onPinToggle,
|
|
17
|
+
onEdit,
|
|
18
|
+
onCopy,
|
|
19
|
+
onDelete,
|
|
20
|
+
onDeleteForMe,
|
|
21
|
+
pinLabel = 'Pin',
|
|
22
|
+
unpinLabel = 'Unpin',
|
|
23
|
+
editLabel = 'Edit',
|
|
24
|
+
copyLabel = 'Copy',
|
|
25
|
+
deleteForMeLabel = 'Delete for me',
|
|
26
|
+
deleteForEveryoneLabel = 'Delete for everyone',
|
|
27
|
+
}) => {
|
|
28
|
+
const { setQuotedMessage, setEditingMessage, setForwardingMessage, activeChannel } = useChatClient();
|
|
29
|
+
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null);
|
|
30
|
+
const actions = useMessageActions(message, isOwnMessage);
|
|
31
|
+
|
|
32
|
+
// Default handlers
|
|
33
|
+
const onReply = onReplyProp ?? ((msg: FormatMessageResponse) => setQuotedMessage(msg));
|
|
34
|
+
const onForwardHandler = onForward ?? ((msg: FormatMessageResponse) => setForwardingMessage(msg));
|
|
35
|
+
const onPinToggleHandler = onPinToggle ?? (async (msg: FormatMessageResponse, isPinned: boolean) => {
|
|
36
|
+
if (!activeChannel) return;
|
|
37
|
+
try {
|
|
38
|
+
if (isPinned) {
|
|
39
|
+
await activeChannel.unpinMessage(msg.id!);
|
|
40
|
+
} else {
|
|
41
|
+
await activeChannel.pinMessage(msg.id!);
|
|
42
|
+
}
|
|
43
|
+
} catch (err) {
|
|
44
|
+
console.error('Failed to toggle pin', err);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
const onEditHandler = onEdit ?? ((msg: FormatMessageResponse) => setEditingMessage(msg));
|
|
48
|
+
|
|
49
|
+
const onDeleteForEveryoneHandler = onDelete ?? (async (msg: FormatMessageResponse) => {
|
|
50
|
+
if (!activeChannel) return;
|
|
51
|
+
try {
|
|
52
|
+
await activeChannel.deleteMessage(msg.id!);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
console.error('Failed to delete message', err);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
const onDeleteForMeHandler = onDeleteForMe ?? (async (msg: FormatMessageResponse) => {
|
|
59
|
+
if (!activeChannel) return;
|
|
60
|
+
try {
|
|
61
|
+
await activeChannel.deleteMessageForMe(msg.id!);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
console.error('Failed to delete message for me', err);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const isOpen = anchorRect !== null;
|
|
68
|
+
const onClose = useCallback(() => setAnchorRect(null), []);
|
|
69
|
+
|
|
70
|
+
const handleMoreClick = (e: React.MouseEvent) => {
|
|
71
|
+
e.preventDefault();
|
|
72
|
+
e.stopPropagation();
|
|
73
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
74
|
+
setAnchorRect(rect);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const handleCopy = async () => {
|
|
78
|
+
if (onCopy) {
|
|
79
|
+
onCopy(message);
|
|
80
|
+
} else if (message.text) {
|
|
81
|
+
try {
|
|
82
|
+
await navigator.clipboard.writeText(message.text);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
console.error('Failed to copy text:', err);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
onClose();
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
return (
|
|
91
|
+
<>
|
|
92
|
+
<div className={`ermis-message-list__actions ${isOpen ? 'ermis-message-list__actions--active' : ''}`}>
|
|
93
|
+
{actions.canReply && (
|
|
94
|
+
<button
|
|
95
|
+
className="ermis-message-list__actions-trigger"
|
|
96
|
+
onClick={() => onReply?.(message)}
|
|
97
|
+
title="Reply"
|
|
98
|
+
disabled={!actions.hasCapReply}
|
|
99
|
+
>
|
|
100
|
+
<svg width="15" height="15" viewBox="0 0 24 24" fill="currentColor">
|
|
101
|
+
<path d="M11.192 15.757c0-.88-.23-1.618-.69-2.217-.326-.412-.768-.683-1.327-.812-.55-.128-1.07-.137-1.54-.028-.16-.95.1-1.956.76-3.022.66-1.065 1.515-1.867 2.558-2.403L9.373 5c-1.368.647-2.525 1.612-3.468 2.895-.943 1.28-1.452 2.673-1.526 4.174-.015.228-.022.463-.022.705 0 1.594.417 2.9 1.25 3.918.835 1.019 1.955 1.53 3.36 1.53 1.048 0 1.903-.311 2.565-.933.66-.622.99-1.465.99-2.53zm10.455 0c0-.88-.23-1.618-.69-2.217-.326-.412-.768-.683-1.327-.812-.55-.128-1.07-.137-1.54-.028-.16-.95.1-1.956.76-3.022.66-1.065 1.515-1.867 2.558-2.403L19.828 5c-1.368.647-2.525 1.612-3.468 2.895-.943 1.28-1.452 2.673-1.526 4.174-.015.228-.022.463-.022.705 0 1.594.417 2.9 1.25 3.918.835 1.019 1.954 1.53 3.36 1.53 1.048 0 1.903-.311 2.565-.933.66-.622.99-1.465.99-2.53z" />
|
|
102
|
+
</svg>
|
|
103
|
+
</button>
|
|
104
|
+
)}
|
|
105
|
+
{actions.canForward && (
|
|
106
|
+
<button
|
|
107
|
+
className="ermis-message-list__actions-trigger"
|
|
108
|
+
onClick={() => onForwardHandler(message)}
|
|
109
|
+
title="Forward"
|
|
110
|
+
disabled={!actions.hasCapQuote}
|
|
111
|
+
>
|
|
112
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
113
|
+
<polyline points="15 14 20 9 15 4" />
|
|
114
|
+
<path d="M4 20v-7a4 4 0 0 1 4-4h12" />
|
|
115
|
+
</svg>
|
|
116
|
+
</button>
|
|
117
|
+
)}
|
|
118
|
+
<button
|
|
119
|
+
className={`ermis-message-list__actions-trigger ${isOpen ? 'ermis-message-list__actions-trigger--active' : ''}`}
|
|
120
|
+
onClick={handleMoreClick}
|
|
121
|
+
title="More actions"
|
|
122
|
+
>
|
|
123
|
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
124
|
+
<circle cx="12" cy="12" r="1" />
|
|
125
|
+
<circle cx="12" cy="5" r="1" />
|
|
126
|
+
<circle cx="12" cy="19" r="1" />
|
|
127
|
+
</svg>
|
|
128
|
+
</button>
|
|
129
|
+
</div>
|
|
130
|
+
|
|
131
|
+
<Dropdown
|
|
132
|
+
isOpen={isOpen}
|
|
133
|
+
anchorRect={anchorRect}
|
|
134
|
+
onClose={onClose}
|
|
135
|
+
align={isOwnMessage ? 'right' : 'left'}
|
|
136
|
+
>
|
|
137
|
+
<div className="ermis-dropdown__menu">
|
|
138
|
+
{actions.canPin && (
|
|
139
|
+
<button
|
|
140
|
+
className="ermis-dropdown__item"
|
|
141
|
+
onClick={() => { onPinToggleHandler(message, actions.isPinned); onClose(); }}
|
|
142
|
+
disabled={!actions.hasCapPin}
|
|
143
|
+
>
|
|
144
|
+
{actions.isPinned ? unpinLabel : pinLabel}
|
|
145
|
+
</button>
|
|
146
|
+
)}
|
|
147
|
+
{actions.canEdit && (
|
|
148
|
+
<button
|
|
149
|
+
className="ermis-dropdown__item"
|
|
150
|
+
onClick={() => { onEditHandler(message); onClose(); }}
|
|
151
|
+
disabled={!actions.hasCapEdit}
|
|
152
|
+
>
|
|
153
|
+
{editLabel}
|
|
154
|
+
</button>
|
|
155
|
+
)}
|
|
156
|
+
{actions.canCopy && (
|
|
157
|
+
<button className="ermis-dropdown__item" onClick={handleCopy}>
|
|
158
|
+
{copyLabel}
|
|
159
|
+
</button>
|
|
160
|
+
)}
|
|
161
|
+
|
|
162
|
+
{(actions.canDelete || actions.canDeleteForMe) && <div className="ermis-dropdown__divider" />}
|
|
163
|
+
|
|
164
|
+
{actions.canDeleteForMe && (
|
|
165
|
+
<button
|
|
166
|
+
className="ermis-dropdown__item ermis-dropdown__item--danger"
|
|
167
|
+
onClick={() => { onDeleteForMeHandler(message); onClose(); }}
|
|
168
|
+
disabled={!actions.hasCapDeleteForMe}
|
|
169
|
+
>
|
|
170
|
+
{deleteForMeLabel}
|
|
171
|
+
</button>
|
|
172
|
+
)}
|
|
173
|
+
{actions.canDelete && (
|
|
174
|
+
<button
|
|
175
|
+
className="ermis-dropdown__item ermis-dropdown__item--danger"
|
|
176
|
+
onClick={() => { onDeleteForEveryoneHandler(message); onClose(); }}
|
|
177
|
+
disabled={!actions.hasCapDelete}
|
|
178
|
+
>
|
|
179
|
+
{deleteForEveryoneLabel}
|
|
180
|
+
</button>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
</Dropdown>
|
|
184
|
+
</>
|
|
185
|
+
);
|
|
186
|
+
};
|