@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,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
+ };