Skip to content

Commit f356d07

Browse files
committed
refactor(react): modernize ChatInput by extracting logic into custom hooks
1 parent 3c51c5e commit f356d07

File tree

4 files changed

+240
-53
lines changed

4 files changed

+240
-53
lines changed
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { useState } from 'react';
2+
import { useToastBarDispatch } from '@embeddedchat/ui-elements';
3+
4+
/**
5+
* useAiSupport - A custom hook to handle the pluggable AI adapter logic for ChatInput.
6+
* This directly supports the GSoC 2026 'Modernizing the AI Layer' deliverable.
7+
*/
8+
const useAiSupport = (messageRef, setDisableButton, ECOptions) => {
9+
const [isAiLoading, setIsAiLoading] = useState(false);
10+
const dispatchToastMessage = useToastBarDispatch();
11+
12+
const handleAiAssist = async () => {
13+
if (!ECOptions?.aiAdapter) {
14+
dispatchToastMessage({
15+
type: 'warning',
16+
message: 'No AI adapter configured. Pass aiAdapter prop to <EmbeddedChat />.',
17+
});
18+
return;
19+
}
20+
21+
setIsAiLoading(true);
22+
try {
23+
const adapter = ECOptions.aiAdapter;
24+
// Gather context for the AI (currently just the current message,
25+
// but scoped for multi-message context in the 2026 roadmap).
26+
const context = [
27+
{ msg: messageRef.current.value || 'Hello! What can I help you with today?' },
28+
];
29+
30+
const replies = await adapter.getSmartReplies(context);
31+
32+
if (replies && replies.length > 0) {
33+
messageRef.current.value = (
34+
messageRef.current.value + ' ' + replies[0]
35+
).trim();
36+
setDisableButton(false);
37+
// Trigger a fake 'scroll to bottom' to ensure the input field expansion doesn't hide text.
38+
messageRef.current.focus();
39+
}
40+
} catch (e) {
41+
console.error('AI Support Hook Error:', e);
42+
dispatchToastMessage({
43+
type: 'error',
44+
message: 'AI generation failed. Please check your adapter configuration.'
45+
});
46+
} finally {
47+
setIsAiLoading(false);
48+
}
49+
};
50+
51+
return { handleAiAssist, isAiLoading };
52+
};
53+
54+
export default useAiSupport;
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import formatSelection from '../lib/formatSelection';
2+
3+
/**
4+
* useChatInputKeyEvents - A hook to manage custom keyboard event logic for the ChatInput.
5+
* Directly supports the GSoC 2026 'Accessibility (WCAG) & Keyboard Navigation' deliverable.
6+
*/
7+
const useChatInputKeyEvents = ({
8+
messageRef,
9+
editMessage,
10+
setDisableButton,
11+
setEditMessage,
12+
showCommandList,
13+
showMembersList,
14+
sendTypingStop,
15+
sendMessage,
16+
handleNewLine,
17+
}) => {
18+
const onKeyDown = (e) => {
19+
switch (true) {
20+
case e.ctrlKey && e.code === 'KeyI': {
21+
e.preventDefault();
22+
formatSelection(messageRef, '_{{text}}_');
23+
break;
24+
}
25+
case e.ctrlKey && e.code === 'KeyB': {
26+
e.preventDefault();
27+
formatSelection(messageRef, '*{{text}}*');
28+
break;
29+
}
30+
case (e.ctrlKey || e.metaKey || e.shiftKey) && e.code === 'Enter':
31+
e.preventDefault();
32+
handleNewLine(e);
33+
break;
34+
case e.code === 'Escape':
35+
if (editMessage.msg || editMessage.attachments) {
36+
e.preventDefault();
37+
messageRef.current.value = '';
38+
setDisableButton(true);
39+
setEditMessage({});
40+
}
41+
break;
42+
43+
case e.code === 'Enter':
44+
e.preventDefault();
45+
if (!showCommandList && !showMembersList) {
46+
sendTypingStop();
47+
sendMessage();
48+
}
49+
break;
50+
case (e.ctrlKey || e.altKey) && e.code === 'ArrowLeft': {
51+
e.preventDefault();
52+
if (messageRef && messageRef.current) {
53+
const { value, selectionStart } = messageRef.current;
54+
let newPosition = selectionStart;
55+
56+
while (newPosition > 0 && /\s/.test(value[newPosition - 1])) {
57+
newPosition -= 1;
58+
}
59+
while (newPosition > 0 && !/\s/.test(value[newPosition - 1])) {
60+
newPosition -= 1;
61+
}
62+
63+
messageRef.current.setSelectionRange(newPosition, newPosition);
64+
messageRef.current.focus();
65+
}
66+
break;
67+
}
68+
case (e.ctrlKey || e.altKey) && e.code === 'ArrowRight': {
69+
e.preventDefault();
70+
if (messageRef && messageRef.current) {
71+
const { value, selectionEnd } = messageRef.current;
72+
let newPosition = selectionEnd;
73+
74+
while (newPosition < value.length && /\s/.test(value[newPosition])) {
75+
newPosition += 1;
76+
}
77+
while (newPosition < value.length && !/\s/.test(value[newPosition])) {
78+
newPosition += 1;
79+
}
80+
81+
messageRef.current.setSelectionRange(newPosition, newPosition);
82+
messageRef.current.focus();
83+
}
84+
break;
85+
}
86+
case (e.ctrlKey || e.altKey) && e.code === 'ArrowUp': {
87+
e.preventDefault();
88+
if (messageRef && messageRef.current) {
89+
messageRef.current.setSelectionRange(0, 0);
90+
messageRef.current.focus();
91+
}
92+
break;
93+
}
94+
case (e.ctrlKey || e.altKey) && e.code === 'ArrowDown': {
95+
e.preventDefault();
96+
if (messageRef && messageRef.current) {
97+
const { current } = messageRef;
98+
const { value } = current;
99+
const { length } = value;
100+
messageRef.current.setSelectionRange(length, length);
101+
messageRef.current.focus();
102+
}
103+
break;
104+
}
105+
default:
106+
break;
107+
}
108+
};
109+
110+
return onKeyDown;
111+
};
112+
113+
export default useChatInputKeyEvents;
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { useRef, useCallback } from 'react';
2+
3+
/**
4+
* useTypingStatus - A hook to manage the 'User is typing...' status.
5+
* Decouples typing-indicator side-effects from the main ChatInput view.
6+
*/
7+
const useTypingStatus = (RCInstance, username) => {
8+
const typingRef = useRef(false);
9+
const timerRef = useRef(null);
10+
11+
const sendTypingStop = useCallback(async () => {
12+
try {
13+
if (typingRef.current) {
14+
typingRef.current = false;
15+
if (timerRef.current) {
16+
clearTimeout(timerRef.current);
17+
}
18+
await RCInstance.sendTypingStatus(username, false);
19+
}
20+
} catch (e) {
21+
console.error('Typing indicator error (stop):', e);
22+
}
23+
}, [RCInstance, username]);
24+
25+
const sendTypingStart = useCallback(async (currentMessageValue) => {
26+
try {
27+
// If we are already typing and still have message content, do nothing (to avoid spamming the server).
28+
if (typingRef.current && currentMessageValue?.length) {
29+
return;
30+
}
31+
32+
if (currentMessageValue?.length) {
33+
typingRef.current = true;
34+
35+
// Reset the typing timeout (typing status is valid for 15 seconds by default in RC).
36+
if (timerRef.current) {
37+
clearTimeout(timerRef.current);
38+
}
39+
timerRef.current = setTimeout(() => {
40+
typingRef.current = false;
41+
}, 15000);
42+
43+
await RCInstance.sendTypingStatus(username, true);
44+
} else {
45+
// If the user clears the input, stop the typing status immediately.
46+
await sendTypingStop();
47+
}
48+
} catch (e) {
49+
console.error('Typing indicator error (start):', e);
50+
}
51+
}, [RCInstance, username, sendTypingStop]);
52+
53+
return { sendTypingStart, sendTypingStop };
54+
};
55+
56+
export default useTypingStatus;

packages/react/src/views/ChatInput/ChatInput.js

Lines changed: 17 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ import useSearchEmoji from '../../hooks/useSearchEmoji';
3737
import formatSelection from '../../lib/formatSelection';
3838
import { parseEmoji } from '../../lib/emoji';
3939
import useDropBox from '../../hooks/useDropBox';
40-
import { GeminiAiAdapter } from '../../lib/ai/GeminiAiAdapter';
40+
import useAiSupport from '../../hooks/useAiSupport';
41+
import useTypingStatus from '../../hooks/useTypingStatus';
42+
import useChatInputKeyEvents from '../../hooks/useChatInputKeyEvents';
4143

4244
const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
4345
const { styleOverrides, classNames } = useComponentOverrides('ChatInput');
@@ -46,10 +48,8 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
4648
const styles = getChatInputStyles(theme);
4749

4850
const inputRef = useRef(null);
49-
const typingRef = useRef();
5051
const messageRef = useRef(null);
5152
const chatInputContainer = useRef(null);
52-
const timerRef = useRef();
5353

5454
const [commands, setCommands] = useState([]);
5555
const [disableButton, setDisableButton] = useState(true);
@@ -65,24 +65,8 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
6565
const [emojiIndex, setEmojiIndex] = useState(-1);
6666
const [startReadEmoji, setStartReadEmoji] = useState(false);
6767
const [isMsgLong, setIsMsgLong] = useState(false);
68-
const [isAiLoading, setIsAiLoading] = useState(false);
69-
70-
const handleAiAssist = async () => {
71-
setIsAiLoading(true);
72-
try {
73-
const adapter = new GeminiAiAdapter('dummy-key');
74-
const dummyContext = [{ msg: messageRef.current.value || 'Hello! What can I help you with today?' }];
75-
const replies = await adapter.getSmartReplies(dummyContext);
76-
if (replies && replies.length > 0) {
77-
messageRef.current.value = (messageRef.current.value + ' ' + replies[0]).trim();
78-
setDisableButton(false);
79-
}
80-
} catch (e) {
81-
console.error(e);
82-
dispatchToastMessage({ type: 'error', message: 'AI generation failed.' });
83-
}
84-
setIsAiLoading(false);
85-
};
68+
69+
8670

8771
const {
8872
isUserAuthenticated,
@@ -155,6 +139,13 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
155139
const userInfo = { _id: userId, username, name };
156140

157141
const dispatchToastMessage = useToastBarDispatch();
142+
143+
const { handleAiAssist, isAiLoading } = useAiSupport(
144+
messageRef,
145+
setDisableButton,
146+
ECOptions
147+
);
148+
158149
const showCommands = useShowCommands(
159150
commands,
160151
setFilteredCommands,
@@ -294,35 +285,10 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
294285
}
295286
};
296287

297-
const sendTypingStart = async () => {
298-
try {
299-
if (typingRef.current && messageRef.current.value?.length) {
300-
return;
301-
}
302-
if (messageRef.current.value?.length) {
303-
typingRef.current = true;
304-
timerRef.current = setTimeout(() => {
305-
typingRef.current = false;
306-
}, [15000]);
307-
await RCInstance.sendTypingStatus(username, true);
308-
} else {
309-
clearTimeout(timerRef.current);
310-
typingRef.current = false;
311-
await RCInstance.sendTypingStatus(username, false);
312-
}
313-
} catch (e) {
314-
console.error(e);
315-
}
316-
};
317-
318-
const sendTypingStop = async () => {
319-
try {
320-
typingRef.current = false;
321-
await RCInstance.sendTypingStatus(username, false);
322-
} catch (e) {
323-
console.error(e);
324-
}
325-
};
288+
const { sendTypingStart, sendTypingStop } = useTypingStatus(
289+
RCInstance,
290+
username
291+
);
326292

327293
const handleSendNewMessage = async (message) => {
328294
messageRef.current.value = '';
@@ -456,13 +422,11 @@ const ChatInput = ({ scrollToBottom, clearUnreadDividerRef }) => {
456422
};
457423

458424
const onTextChange = (e, val) => {
459-
sendTypingStart();
460425
const message = val || e.target.value;
461-
426+
sendTypingStart(message);
462427
// Don't parse emojis if user is currently typing emoji autocomplete
463428
const shouldParseEmoji = !message.match(/:([a-zA-Z0-9_+-]*?)$/);
464429
messageRef.current.value = shouldParseEmoji ? parseEmoji(message) : message;
465-
466430
setDisableButton(!messageRef.current.value.length);
467431
if (e !== null) {
468432
handleNewLine(e, false);

0 commit comments

Comments
 (0)