@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,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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
19
+ return `<span class="${MENTION_SPAN_CLASS}" data-mention-id="${userId}" contenteditable="false">@${safeName}</span>&nbsp;`;
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
+ };