@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,128 @@
|
|
|
1
|
+
import { useState, useCallback, useRef } from 'react';
|
|
2
|
+
import { isHeicFile, isVideoFile, normalizeFileName } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import type { Channel } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import type { FilePreviewItem } from '../types';
|
|
5
|
+
|
|
6
|
+
let _fileIdCounter = 0;
|
|
7
|
+
function nextFileId(): string {
|
|
8
|
+
return `file-${Date.now()}-${++_fileIdCounter}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type UseFileUploadOptions = {
|
|
12
|
+
activeChannel: Channel | null;
|
|
13
|
+
editableRef: React.RefObject<HTMLDivElement | null>;
|
|
14
|
+
setHasContent: (value: boolean) => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function useFileUpload({ activeChannel, editableRef, setHasContent }: UseFileUploadOptions) {
|
|
18
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
19
|
+
const [files, setFiles] = useState<FilePreviewItem[]>([]);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Upload a single file immediately:
|
|
23
|
+
* 1. Normalize file name
|
|
24
|
+
* 2. Call sendFile API
|
|
25
|
+
* 3. For video: generate + upload thumbnail
|
|
26
|
+
* 4. Update file item state with uploaded URL
|
|
27
|
+
*/
|
|
28
|
+
const uploadSingleFile = useCallback(async (item: FilePreviewItem) => {
|
|
29
|
+
if (!activeChannel) return;
|
|
30
|
+
|
|
31
|
+
try {
|
|
32
|
+
const file = item.file!;
|
|
33
|
+
const normalizedName = normalizeFileName(file.name);
|
|
34
|
+
const fileToUpload = normalizedName !== file.name
|
|
35
|
+
? new File([file], normalizedName, { type: file.type, lastModified: file.lastModified })
|
|
36
|
+
: file;
|
|
37
|
+
|
|
38
|
+
const response = await activeChannel.sendFile(fileToUpload, fileToUpload.name, fileToUpload.type);
|
|
39
|
+
const uploadedUrl = response.file;
|
|
40
|
+
|
|
41
|
+
let thumbUrl = '';
|
|
42
|
+
if (isVideoFile(file)) {
|
|
43
|
+
try {
|
|
44
|
+
const thumbBlob = await activeChannel.getThumbBlobVideo(file);
|
|
45
|
+
if (thumbBlob) {
|
|
46
|
+
const thumbFile = new File([thumbBlob], `thumb_${normalizedName}.jpg`, { type: 'image/jpeg' });
|
|
47
|
+
const thumbResp = await activeChannel.sendFile(thumbFile, thumbFile.name, 'image/jpeg');
|
|
48
|
+
thumbUrl = thumbResp.file;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// Thumbnail failure is non-critical
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setFiles((prev) =>
|
|
56
|
+
prev.map((f) =>
|
|
57
|
+
f.id === item.id
|
|
58
|
+
? { ...f, status: 'done' as const, uploadedUrl, thumbUrl, normalizedFile: fileToUpload }
|
|
59
|
+
: f,
|
|
60
|
+
),
|
|
61
|
+
);
|
|
62
|
+
} catch (err: any) {
|
|
63
|
+
setFiles((prev) =>
|
|
64
|
+
prev.map((f) =>
|
|
65
|
+
f.id === item.id
|
|
66
|
+
? { ...f, status: 'error' as const, error: err?.message || 'Upload failed' }
|
|
67
|
+
: f,
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
}, [activeChannel]);
|
|
72
|
+
|
|
73
|
+
const handleFilesSelected = useCallback((selectedFiles: FileList | null) => {
|
|
74
|
+
if (!selectedFiles || selectedFiles.length === 0) return;
|
|
75
|
+
|
|
76
|
+
const newItems: FilePreviewItem[] = Array.from(selectedFiles).map((file) => {
|
|
77
|
+
const isPreviewable =
|
|
78
|
+
(file.type.startsWith('image/') && !isHeicFile(file)) ||
|
|
79
|
+
file.type.startsWith('video/');
|
|
80
|
+
return {
|
|
81
|
+
id: nextFileId(),
|
|
82
|
+
file,
|
|
83
|
+
previewUrl: isPreviewable ? URL.createObjectURL(file) : undefined,
|
|
84
|
+
status: 'uploading' as const,
|
|
85
|
+
};
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
setFiles((prev) => [...prev, ...newItems]);
|
|
89
|
+
setHasContent(true);
|
|
90
|
+
|
|
91
|
+
newItems.forEach((item) => uploadSingleFile(item));
|
|
92
|
+
}, [uploadSingleFile, setHasContent]);
|
|
93
|
+
|
|
94
|
+
const handleRemoveFile = useCallback((id: string) => {
|
|
95
|
+
setFiles((prev) => {
|
|
96
|
+
const item = prev.find((f) => f.id === id);
|
|
97
|
+
if (item?.previewUrl) URL.revokeObjectURL(item.previewUrl);
|
|
98
|
+
const remaining = prev.filter((f) => f.id !== id);
|
|
99
|
+
const el = editableRef.current;
|
|
100
|
+
const textContent = el?.textContent?.trim() ?? '';
|
|
101
|
+
if (remaining.length === 0 && textContent.length === 0) {
|
|
102
|
+
setHasContent(false);
|
|
103
|
+
}
|
|
104
|
+
return remaining;
|
|
105
|
+
});
|
|
106
|
+
}, [editableRef, setHasContent]);
|
|
107
|
+
|
|
108
|
+
const handleAttachClick = useCallback(() => {
|
|
109
|
+
fileInputRef.current?.click();
|
|
110
|
+
}, []);
|
|
111
|
+
|
|
112
|
+
// Cleanup blob URLs
|
|
113
|
+
const cleanupFiles = useCallback(() => {
|
|
114
|
+
files.forEach((f) => {
|
|
115
|
+
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl);
|
|
116
|
+
});
|
|
117
|
+
}, [files]);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
files,
|
|
121
|
+
setFiles,
|
|
122
|
+
fileInputRef,
|
|
123
|
+
handleFilesSelected,
|
|
124
|
+
handleRemoveFile,
|
|
125
|
+
handleAttachClick,
|
|
126
|
+
cleanupFiles,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import { useState, useRef, useCallback, useEffect } from 'react';
|
|
2
|
+
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
3
|
+
import { formatMessage } from '@ermis-network/ermis-chat-sdk';
|
|
4
|
+
import type { VListHandle } from 'virtua';
|
|
5
|
+
import { useChatClient } from './useChatClient';
|
|
6
|
+
|
|
7
|
+
const LOAD_MORE_THRESHOLD = 200;
|
|
8
|
+
|
|
9
|
+
/** Filter out messages whose id already exists in `existing` (or self-dedup if omitted). */
|
|
10
|
+
export const dedupMessages = (incoming: any[], existing?: any[]) => {
|
|
11
|
+
const ids = new Set(existing?.map((m) => m.id) ?? []);
|
|
12
|
+
return incoming.filter((m: any) => {
|
|
13
|
+
if (!m.id || ids.has(m.id)) return false;
|
|
14
|
+
ids.add(m.id);
|
|
15
|
+
return true;
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export type UseLoadMessagesOptions = {
|
|
20
|
+
vlistRef: React.RefObject<VListHandle | null>;
|
|
21
|
+
messagesRef: React.MutableRefObject<FormatMessageResponse[]>;
|
|
22
|
+
/** Shared guard ref — skip scroll-triggered loads during jump transitions */
|
|
23
|
+
jumpingRef: React.MutableRefObject<boolean>;
|
|
24
|
+
loadMoreLimit?: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type UseLoadMessagesReturn = {
|
|
28
|
+
/** VList shift mode — true during prepend, auto-resets to false */
|
|
29
|
+
shiftMode: boolean;
|
|
30
|
+
hasMore: boolean;
|
|
31
|
+
setHasMore: React.Dispatch<React.SetStateAction<boolean>>;
|
|
32
|
+
hasNewer: boolean;
|
|
33
|
+
setHasNewer: React.Dispatch<React.SetStateAction<boolean>>;
|
|
34
|
+
hasMoreRef: React.RefObject<boolean>;
|
|
35
|
+
hasNewerRef: React.RefObject<boolean>;
|
|
36
|
+
loadingMoreRef: React.MutableRefObject<boolean>;
|
|
37
|
+
loadingNewerRef: React.MutableRefObject<boolean>;
|
|
38
|
+
loadMore: () => Promise<void>;
|
|
39
|
+
loadNewer: () => Promise<void>;
|
|
40
|
+
handleScroll: (offset: number) => void;
|
|
41
|
+
isAtBottomRef: React.MutableRefObject<boolean>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function useLoadMessages({
|
|
45
|
+
vlistRef,
|
|
46
|
+
messagesRef,
|
|
47
|
+
jumpingRef,
|
|
48
|
+
loadMoreLimit = 25,
|
|
49
|
+
}: UseLoadMessagesOptions): UseLoadMessagesReturn {
|
|
50
|
+
const { activeChannel, setMessages } = useChatClient();
|
|
51
|
+
const [hasMore, setHasMore] = useState(true);
|
|
52
|
+
const [hasNewer, setHasNewer] = useState(false);
|
|
53
|
+
const [shiftMode, setShiftMode] = useState(false);
|
|
54
|
+
|
|
55
|
+
// Auto-reset shiftMode after each prepend render
|
|
56
|
+
useEffect(() => {
|
|
57
|
+
if (shiftMode) {
|
|
58
|
+
requestAnimationFrame(() => setShiftMode(false));
|
|
59
|
+
}
|
|
60
|
+
}, [shiftMode]);
|
|
61
|
+
|
|
62
|
+
// Refs synced from state (avoid handleScroll recreation on state change)
|
|
63
|
+
const hasMoreRef = useRef(true);
|
|
64
|
+
hasMoreRef.current = hasMore;
|
|
65
|
+
const hasNewerRef = useRef(false);
|
|
66
|
+
hasNewerRef.current = hasNewer;
|
|
67
|
+
const isAtBottomRef = useRef(true);
|
|
68
|
+
|
|
69
|
+
// Concurrency guards
|
|
70
|
+
const loadingMoreRef = useRef(false);
|
|
71
|
+
const loadingNewerRef = useRef(false);
|
|
72
|
+
|
|
73
|
+
const loadMore = useCallback(async () => {
|
|
74
|
+
if (!activeChannel || loadingMoreRef.current) return;
|
|
75
|
+
|
|
76
|
+
const currentMessages = messagesRef.current;
|
|
77
|
+
const oldestMessage = currentMessages[0];
|
|
78
|
+
if (!oldestMessage?.id) return;
|
|
79
|
+
|
|
80
|
+
loadingMoreRef.current = true;
|
|
81
|
+
try {
|
|
82
|
+
const olderRaw = await activeChannel.queryMessagesLessThanId(oldestMessage.id, loadMoreLimit);
|
|
83
|
+
|
|
84
|
+
if (olderRaw.length === 0) {
|
|
85
|
+
setHasMore(false);
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const olderFormatted = olderRaw.map((msg: any) => formatMessage(msg));
|
|
90
|
+
setShiftMode(true);
|
|
91
|
+
setMessages((prev) => {
|
|
92
|
+
const unique = dedupMessages(olderFormatted, prev);
|
|
93
|
+
if (unique.length === 0) {
|
|
94
|
+
setHasMore(false);
|
|
95
|
+
}
|
|
96
|
+
return [...unique, ...prev];
|
|
97
|
+
});
|
|
98
|
+
} catch (err) {
|
|
99
|
+
console.error('Failed to load more messages:', err);
|
|
100
|
+
} finally {
|
|
101
|
+
loadingMoreRef.current = false;
|
|
102
|
+
}
|
|
103
|
+
}, [activeChannel, loadMoreLimit, setMessages]);
|
|
104
|
+
|
|
105
|
+
const loadNewer = useCallback(async () => {
|
|
106
|
+
if (!activeChannel || loadingNewerRef.current) return;
|
|
107
|
+
|
|
108
|
+
const currentMessages = messagesRef.current;
|
|
109
|
+
const newestMessage = currentMessages[currentMessages.length - 1];
|
|
110
|
+
if (!newestMessage?.id) return;
|
|
111
|
+
|
|
112
|
+
loadingNewerRef.current = true;
|
|
113
|
+
try {
|
|
114
|
+
const newerRaw = await activeChannel.queryMessagesGreaterThanId(newestMessage.id, loadMoreLimit);
|
|
115
|
+
|
|
116
|
+
if (newerRaw.length === 0) {
|
|
117
|
+
setHasNewer(false);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const newerFormatted = newerRaw.map((msg: any) => formatMessage(msg));
|
|
122
|
+
setMessages((prev) => {
|
|
123
|
+
const unique = dedupMessages(newerFormatted, prev);
|
|
124
|
+
if (unique.length === 0) {
|
|
125
|
+
setHasNewer(false);
|
|
126
|
+
}
|
|
127
|
+
return [...prev, ...unique];
|
|
128
|
+
});
|
|
129
|
+
} catch (err) {
|
|
130
|
+
console.error('Failed to load newer messages:', err);
|
|
131
|
+
} finally {
|
|
132
|
+
loadingNewerRef.current = false;
|
|
133
|
+
}
|
|
134
|
+
}, [activeChannel, loadMoreLimit, setMessages]);
|
|
135
|
+
|
|
136
|
+
const handleScroll = useCallback(
|
|
137
|
+
(offset: number) => {
|
|
138
|
+
if (jumpingRef.current) return;
|
|
139
|
+
const handle = vlistRef.current;
|
|
140
|
+
if (!handle) return;
|
|
141
|
+
const { scrollSize, viewportSize } = handle;
|
|
142
|
+
|
|
143
|
+
const isBottom = Math.ceil(offset + viewportSize) >= scrollSize - 20;
|
|
144
|
+
isAtBottomRef.current = isBottom;
|
|
145
|
+
|
|
146
|
+
// Skip if content doesn't fill the viewport
|
|
147
|
+
if (scrollSize <= viewportSize) {
|
|
148
|
+
isAtBottomRef.current = true;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (offset <= LOAD_MORE_THRESHOLD && hasMoreRef.current) {
|
|
153
|
+
loadMore();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (offset + viewportSize >= scrollSize - LOAD_MORE_THRESHOLD && hasNewerRef.current) {
|
|
157
|
+
loadNewer();
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
[loadMore, loadNewer],
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
return {
|
|
164
|
+
shiftMode,
|
|
165
|
+
hasMore,
|
|
166
|
+
setHasMore,
|
|
167
|
+
hasNewer,
|
|
168
|
+
setHasNewer,
|
|
169
|
+
hasMoreRef,
|
|
170
|
+
hasNewerRef,
|
|
171
|
+
loadingMoreRef,
|
|
172
|
+
loadingNewerRef,
|
|
173
|
+
loadMore,
|
|
174
|
+
loadNewer,
|
|
175
|
+
handleScroll,
|
|
176
|
+
isAtBottomRef,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import { useState, useCallback, useRef, useDeferredValue, useMemo } from 'react';
|
|
2
|
+
import { moveCaretAfterNode } from '../utils';
|
|
3
|
+
import type {
|
|
4
|
+
MentionMember,
|
|
5
|
+
MentionPayload,
|
|
6
|
+
UseMentionsOptions,
|
|
7
|
+
UseMentionsReturn,
|
|
8
|
+
} from '../types';
|
|
9
|
+
|
|
10
|
+
export type { MentionMember, MentionPayload, UseMentionsOptions, UseMentionsReturn } from '../types';
|
|
11
|
+
|
|
12
|
+
export const MENTION_SPAN_CLASS = 'ermis-message-input__mention-span';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Returns the raw HTML string for a mention span, useful for initializing contenteditable divs.
|
|
16
|
+
*/
|
|
17
|
+
export function getMentionHtml(userId: string, displayName: string): string {
|
|
18
|
+
const safeName = displayName.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
19
|
+
return `<span class="${MENTION_SPAN_CLASS}" data-mention-id="${userId}" contenteditable="false">@${safeName}</span> `;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Insert an atomic mention <span> at the current cursor position inside a
|
|
24
|
+
* contenteditable element, followed by a trailing space.
|
|
25
|
+
*/
|
|
26
|
+
function insertMentionAtCursor(
|
|
27
|
+
editableEl: HTMLElement,
|
|
28
|
+
userId: string,
|
|
29
|
+
displayName: string,
|
|
30
|
+
) {
|
|
31
|
+
const sel = window.getSelection();
|
|
32
|
+
if (!sel || sel.rangeCount === 0) return;
|
|
33
|
+
|
|
34
|
+
const range = sel.getRangeAt(0);
|
|
35
|
+
|
|
36
|
+
const { startContainer, startOffset } = range;
|
|
37
|
+
if (startContainer.nodeType === Node.TEXT_NODE) {
|
|
38
|
+
const textBefore = startContainer.textContent?.slice(0, startOffset) ?? '';
|
|
39
|
+
const atIndex = textBefore.lastIndexOf('@');
|
|
40
|
+
if (atIndex !== -1) {
|
|
41
|
+
range.setStart(startContainer, atIndex);
|
|
42
|
+
range.deleteContents();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const span = document.createElement('span');
|
|
47
|
+
span.className = MENTION_SPAN_CLASS;
|
|
48
|
+
span.setAttribute('data-mention-id', userId);
|
|
49
|
+
span.contentEditable = 'false';
|
|
50
|
+
span.textContent = `@${displayName}`;
|
|
51
|
+
|
|
52
|
+
range.insertNode(span);
|
|
53
|
+
|
|
54
|
+
const space = document.createTextNode('\u00A0');
|
|
55
|
+
span.after(space);
|
|
56
|
+
moveCaretAfterNode(space);
|
|
57
|
+
|
|
58
|
+
editableEl.dispatchEvent(new Event('input', { bubbles: true }));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Parse the DOM of a contenteditable div to produce a mention payload.
|
|
63
|
+
*/
|
|
64
|
+
function buildPayloadFromDOM(editableEl: HTMLElement): MentionPayload {
|
|
65
|
+
let text = '';
|
|
66
|
+
let mentionedAll = false;
|
|
67
|
+
const mentionedUsers: string[] = [];
|
|
68
|
+
|
|
69
|
+
function walk(node: Node) {
|
|
70
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
71
|
+
text += node.textContent ?? '';
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (node instanceof HTMLElement) {
|
|
76
|
+
const mentionId = node.getAttribute('data-mention-id');
|
|
77
|
+
if (mentionId && node.classList.contains(MENTION_SPAN_CLASS)) {
|
|
78
|
+
if (mentionId === '__all__') {
|
|
79
|
+
mentionedAll = true;
|
|
80
|
+
text += '@all';
|
|
81
|
+
} else {
|
|
82
|
+
if (!mentionedUsers.includes(mentionId)) {
|
|
83
|
+
mentionedUsers.push(mentionId);
|
|
84
|
+
}
|
|
85
|
+
text += `@${mentionId}`;
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (node.tagName === 'BR') {
|
|
91
|
+
text += '\n';
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (node.tagName === 'DIV' && text.length > 0 && !text.endsWith('\n')) {
|
|
96
|
+
text += '\n';
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
node.childNodes.forEach(walk);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
walk(editableEl);
|
|
104
|
+
text = text.replace(/\u00A0/g, ' ').trim();
|
|
105
|
+
|
|
106
|
+
return { text, mentioned_all: mentionedAll, mentioned_users: mentionedUsers };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scan the DOM for currently present mention spans and return their IDs.
|
|
111
|
+
*/
|
|
112
|
+
function getActiveMentionIds(editableEl: HTMLElement): Set<string> {
|
|
113
|
+
const ids = new Set<string>();
|
|
114
|
+
const spans = editableEl.querySelectorAll(`.${MENTION_SPAN_CLASS}`);
|
|
115
|
+
spans.forEach((span) => {
|
|
116
|
+
const id = span.getAttribute('data-mention-id');
|
|
117
|
+
if (id) ids.add(id);
|
|
118
|
+
});
|
|
119
|
+
return ids;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function useMentions({
|
|
123
|
+
members,
|
|
124
|
+
currentUserId,
|
|
125
|
+
editableRef,
|
|
126
|
+
}: UseMentionsOptions): UseMentionsReturn {
|
|
127
|
+
const [showSuggestions, setShowSuggestions] = useState(false);
|
|
128
|
+
const [query, setQuery] = useState('');
|
|
129
|
+
const [highlightIndex, setHighlightIndex] = useState(0);
|
|
130
|
+
const [activeMentionIds, setActiveMentionIds] = useState<Set<string>>(new Set());
|
|
131
|
+
|
|
132
|
+
const deferredQuery = useDeferredValue(query);
|
|
133
|
+
|
|
134
|
+
// All item: special entry
|
|
135
|
+
const allItem: MentionMember = useMemo(
|
|
136
|
+
() => ({ id: '__all__', name: 'all' }),
|
|
137
|
+
[],
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
// Filter members based on deferred query, exclude self and already-mentioned
|
|
141
|
+
const filteredMembers = useMemo(() => {
|
|
142
|
+
const q = deferredQuery.toLowerCase();
|
|
143
|
+
|
|
144
|
+
// Start with @all if not already selected
|
|
145
|
+
const result: MentionMember[] = [];
|
|
146
|
+
if (!activeMentionIds.has('__all__')) {
|
|
147
|
+
if (!q || 'all'.includes(q)) {
|
|
148
|
+
result.push(allItem);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const m of members) {
|
|
153
|
+
if (m.id === currentUserId) continue; // skip self
|
|
154
|
+
if (activeMentionIds.has(m.id)) continue; // skip already mentioned
|
|
155
|
+
if (q && !m.name.toLowerCase().includes(q)) continue; // filter by query
|
|
156
|
+
result.push(m);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return result;
|
|
160
|
+
}, [members, deferredQuery, activeMentionIds, currentUserId, allItem]);
|
|
161
|
+
|
|
162
|
+
// Detect @ trigger from cursor position
|
|
163
|
+
const detectTrigger = useCallback((): { triggered: boolean; query: string } => {
|
|
164
|
+
const sel = window.getSelection();
|
|
165
|
+
if (!sel || sel.rangeCount === 0 || !sel.isCollapsed) {
|
|
166
|
+
return { triggered: false, query: '' };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const { anchorNode, anchorOffset } = sel;
|
|
170
|
+
if (!anchorNode || anchorNode.nodeType !== Node.TEXT_NODE) {
|
|
171
|
+
return { triggered: false, query: '' };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const textBefore = anchorNode.textContent?.slice(0, anchorOffset) ?? '';
|
|
175
|
+
|
|
176
|
+
// Find the last @ that is preceded by a space or is at the start
|
|
177
|
+
const match = textBefore.match(/(^|[\s\u00A0])@(\S*)$/);
|
|
178
|
+
if (!match) {
|
|
179
|
+
return { triggered: false, query: '' };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return { triggered: true, query: match[2] };
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const handleInput = useCallback(() => {
|
|
186
|
+
const el = editableRef.current;
|
|
187
|
+
if (!el) return;
|
|
188
|
+
|
|
189
|
+
// Update active mention IDs by scanning DOM
|
|
190
|
+
setActiveMentionIds(getActiveMentionIds(el));
|
|
191
|
+
|
|
192
|
+
// Detect @ trigger
|
|
193
|
+
const result = detectTrigger();
|
|
194
|
+
if (result.triggered) {
|
|
195
|
+
setShowSuggestions(true);
|
|
196
|
+
setQuery(result.query);
|
|
197
|
+
setHighlightIndex(0);
|
|
198
|
+
} else {
|
|
199
|
+
setShowSuggestions(false);
|
|
200
|
+
setQuery('');
|
|
201
|
+
}
|
|
202
|
+
}, [editableRef, detectTrigger]);
|
|
203
|
+
|
|
204
|
+
const selectMention = useCallback(
|
|
205
|
+
(member: MentionMember) => {
|
|
206
|
+
const el = editableRef.current;
|
|
207
|
+
if (!el) return;
|
|
208
|
+
|
|
209
|
+
insertMentionAtCursor(el, member.id, member.name);
|
|
210
|
+
|
|
211
|
+
// Update tracking
|
|
212
|
+
setActiveMentionIds((prev) => new Set(prev).add(member.id));
|
|
213
|
+
setShowSuggestions(false);
|
|
214
|
+
setQuery('');
|
|
215
|
+
|
|
216
|
+
// Re-focus the editable
|
|
217
|
+
el.focus();
|
|
218
|
+
},
|
|
219
|
+
[editableRef],
|
|
220
|
+
);
|
|
221
|
+
|
|
222
|
+
const handleKeyDown = useCallback(
|
|
223
|
+
(e: React.KeyboardEvent): boolean => {
|
|
224
|
+
if (!showSuggestions || filteredMembers.length === 0) return false;
|
|
225
|
+
|
|
226
|
+
switch (e.key) {
|
|
227
|
+
case 'ArrowDown':
|
|
228
|
+
e.preventDefault();
|
|
229
|
+
setHighlightIndex((prev) =>
|
|
230
|
+
prev < filteredMembers.length - 1 ? prev + 1 : 0,
|
|
231
|
+
);
|
|
232
|
+
return true;
|
|
233
|
+
|
|
234
|
+
case 'ArrowUp':
|
|
235
|
+
e.preventDefault();
|
|
236
|
+
setHighlightIndex((prev) =>
|
|
237
|
+
prev > 0 ? prev - 1 : filteredMembers.length - 1,
|
|
238
|
+
);
|
|
239
|
+
return true;
|
|
240
|
+
|
|
241
|
+
case 'Enter':
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
if (filteredMembers[highlightIndex]) {
|
|
244
|
+
selectMention(filteredMembers[highlightIndex]);
|
|
245
|
+
}
|
|
246
|
+
return true;
|
|
247
|
+
|
|
248
|
+
case 'Escape':
|
|
249
|
+
e.preventDefault();
|
|
250
|
+
setShowSuggestions(false);
|
|
251
|
+
return true;
|
|
252
|
+
|
|
253
|
+
default:
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
[showSuggestions, filteredMembers, highlightIndex, selectMention],
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
const buildPayload = useCallback((): MentionPayload => {
|
|
261
|
+
const el = editableRef.current;
|
|
262
|
+
if (!el) return { text: '', mentioned_all: false, mentioned_users: [] };
|
|
263
|
+
return buildPayloadFromDOM(el);
|
|
264
|
+
}, [editableRef]);
|
|
265
|
+
|
|
266
|
+
const reset = useCallback(() => {
|
|
267
|
+
setShowSuggestions(false);
|
|
268
|
+
setQuery('');
|
|
269
|
+
setHighlightIndex(0);
|
|
270
|
+
setActiveMentionIds(new Set());
|
|
271
|
+
const el = editableRef.current;
|
|
272
|
+
if (el) {
|
|
273
|
+
el.innerHTML = '';
|
|
274
|
+
}
|
|
275
|
+
}, [editableRef]);
|
|
276
|
+
|
|
277
|
+
return {
|
|
278
|
+
showSuggestions,
|
|
279
|
+
filteredMembers,
|
|
280
|
+
highlightIndex,
|
|
281
|
+
handleInput,
|
|
282
|
+
handleKeyDown,
|
|
283
|
+
selectMention,
|
|
284
|
+
buildPayload,
|
|
285
|
+
reset,
|
|
286
|
+
};
|
|
287
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useMemo } from 'react';
|
|
2
|
+
import { useChatClient } from './useChatClient';
|
|
3
|
+
import { useChannelCapabilities } from './useChannelCapabilities';
|
|
4
|
+
import type { FormatMessageResponse } from '@ermis-network/ermis-chat-sdk';
|
|
5
|
+
|
|
6
|
+
export type MessageActionList = {
|
|
7
|
+
canEdit: boolean;
|
|
8
|
+
canDelete: boolean;
|
|
9
|
+
canDeleteForMe: boolean;
|
|
10
|
+
canReply: boolean;
|
|
11
|
+
canQuote: boolean;
|
|
12
|
+
canForward: boolean;
|
|
13
|
+
canPin: boolean;
|
|
14
|
+
canCopy: boolean;
|
|
15
|
+
isPinned: boolean;
|
|
16
|
+
hasCapEdit: boolean;
|
|
17
|
+
hasCapDelete: boolean;
|
|
18
|
+
hasCapDeleteForMe: boolean;
|
|
19
|
+
hasCapPin: boolean;
|
|
20
|
+
hasCapReply: boolean;
|
|
21
|
+
hasCapQuote: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const useMessageActions = (message: FormatMessageResponse, isOwnMessage: boolean): MessageActionList => {
|
|
25
|
+
const { activeChannel, client } = useChatClient();
|
|
26
|
+
const { isTeamChannel: isTeam, isOwner, hasCapability } = useChannelCapabilities();
|
|
27
|
+
|
|
28
|
+
// Only depend on the specific message fields we actually read
|
|
29
|
+
const messageType = message.type;
|
|
30
|
+
const isPinnedFlag = message.pinned || !!message.pinned_at;
|
|
31
|
+
|
|
32
|
+
return useMemo(() => {
|
|
33
|
+
if (!activeChannel) {
|
|
34
|
+
return {
|
|
35
|
+
canEdit: false,
|
|
36
|
+
canDelete: false,
|
|
37
|
+
canDeleteForMe: false,
|
|
38
|
+
canReply: false,
|
|
39
|
+
canQuote: false,
|
|
40
|
+
canForward: false,
|
|
41
|
+
canPin: false,
|
|
42
|
+
canCopy: false,
|
|
43
|
+
isPinned: false,
|
|
44
|
+
hasCapEdit: false,
|
|
45
|
+
hasCapDelete: false,
|
|
46
|
+
hasCapDeleteForMe: false,
|
|
47
|
+
hasCapPin: false,
|
|
48
|
+
hasCapReply: false,
|
|
49
|
+
hasCapQuote: false,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const isSystem = messageType === 'system';
|
|
54
|
+
const isSignal = messageType === 'signal';
|
|
55
|
+
const isPinned = isPinnedFlag;
|
|
56
|
+
|
|
57
|
+
const canEdit = !isSystem && !isSignal && isOwnMessage;
|
|
58
|
+
|
|
59
|
+
// Delete for everyone:
|
|
60
|
+
// + Team channel: only the owner can perform this action natively.
|
|
61
|
+
// + Messaging channel: only own messages can be deleted
|
|
62
|
+
const canDeleteForEveryoneTeam = isTeam && isOwner;
|
|
63
|
+
const canDeleteForEveryoneMessaging = !isTeam && isOwnMessage;
|
|
64
|
+
|
|
65
|
+
const canDelete = !isSystem && (canDeleteForEveryoneTeam || canDeleteForEveryoneMessaging);
|
|
66
|
+
const canDeleteForMe = !isSystem;
|
|
67
|
+
const canReply = !isSystem && !isSignal;
|
|
68
|
+
const canQuote = !isSystem && !isSignal;
|
|
69
|
+
const canForward = !isSystem && !isSignal;
|
|
70
|
+
const canPin = !isSystem && !isSignal;
|
|
71
|
+
const canCopy = !isSystem && !isSignal && Boolean(message.text?.trim());
|
|
72
|
+
|
|
73
|
+
const hasCapEdit = hasCapability('update-own-message');
|
|
74
|
+
const hasCapDelete = !isTeam || isOwner || (isOwnMessage && hasCapability('delete-own-message'));
|
|
75
|
+
// Apply the delete-own-message capability to the "delete for me" action for own messages
|
|
76
|
+
const hasCapDeleteForMe = !isTeam || isOwner || !isOwnMessage || hasCapability('delete-own-message');
|
|
77
|
+
|
|
78
|
+
const hasCapReply = hasCapability('send-reply');
|
|
79
|
+
const hasCapQuote = hasCapability('quote-message');
|
|
80
|
+
const hasCapPin = hasCapability('pin-message');
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
canEdit, canDelete, canDeleteForMe, canReply, canQuote, canForward, canPin, canCopy, isPinned,
|
|
84
|
+
hasCapEdit, hasCapDelete, hasCapDeleteForMe, hasCapPin, hasCapReply, hasCapQuote
|
|
85
|
+
};
|
|
86
|
+
}, [activeChannel, isTeam, isOwner, hasCapability, messageType, message.text, isPinnedFlag, isOwnMessage]); // Use capabilities from hook
|
|
87
|
+
};
|