diff --git a/frontend/src/components/MessageInput.tsx b/frontend/src/components/MessageInput.tsx index 38fd330..f760a0a 100644 --- a/frontend/src/components/MessageInput.tsx +++ b/frontend/src/components/MessageInput.tsx @@ -1,6 +1,14 @@ import { useSocket } from '@/hooks/useSocket'; +import { useAuthStore } from '@/stores/authStore'; import { Mic, Paperclip, Send, Smile } from 'lucide-react'; -import { useEffect, useRef, useState, type FC, type FormEvent } from 'react'; +import { + useEffect, + useRef, + useState, + type ChangeEvent, + type FC, + type FormEvent, +} from 'react'; import { toast } from 'sonner'; import { Button } from './ui/button'; import { Input } from './ui/input'; @@ -14,8 +22,11 @@ export const MessageInput: FC = ({ roomId }) => { const [isRateLimited, setIsRateLimited] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [isFocused, setIsFocused] = useState(false); - const inputRef = useRef(null); + const [isTyping, setIsTyping] = useState(false); const { socket } = useSocket(); + const { user } = useAuthStore(); + const inputRef = useRef(null); + const typingTimeoutRef = useRef(null); useEffect(() => { if (!socket) return; @@ -50,16 +61,57 @@ export const MessageInput: FC = ({ roomId }) => { content: message.trim(), }); + if (isTyping && user) { + socket.emit('typing_stop', { roomId, username: user.username }); + setIsTyping(false); + } + setMessage(''); }; - // Function to handle emoji selection (placeholder for now) const handleEmojiClick = (emoji: string) => { setMessage((prev) => prev + emoji); inputRef.current?.focus(); setShowEmojiPicker(false); }; + const handleTypingStart = () => { + if (!socket || !user || isRateLimited) return; + + if (!isTyping) { + socket.emit('typing_start', { roomId, username: user.username }); + setIsTyping(true); + } + + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + typingTimeoutRef.current = setTimeout(() => { + if (socket && isTyping && user) { + socket.emit('typing_stop', { roomId, username: user.username }); + setIsTyping(false); + } + }, 5000); + }; + + const handleInputChange = (e: ChangeEvent) => { + setMessage(e.target.value); + handleTypingStart(); + }; + + useEffect(() => { + return () => { + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } + + if (socket && isTyping && user) { + socket.emit('typing_stop', { roomId, username: user.username }); + } + }; + }, [socket, isTyping, user, roomId]); + return (
@@ -91,7 +143,7 @@ export const MessageInput: FC = ({ roomId }) => { setMessage(e.target.value)} + onChange={handleInputChange} onFocus={() => setIsFocused(true)} onBlur={() => setIsFocused(false)} placeholder={ diff --git a/frontend/src/components/MessageList.tsx b/frontend/src/components/MessageList.tsx index 3a97f7a..b1f76fa 100644 --- a/frontend/src/components/MessageList.tsx +++ b/frontend/src/components/MessageList.tsx @@ -4,6 +4,7 @@ import type { Message } from '@/types'; import { useQuery } from '@tanstack/react-query'; import { useEffect, useRef, type FC } from 'react'; import { MessageCard } from './MessageCard'; +import { TypingIndicator } from './TypingIndicator'; import { ScrollArea } from './ui/scroll-area'; type MessageListProps = { @@ -48,6 +49,7 @@ export const MessageList: FC = ({ roomId }) => { ))}
+
); diff --git a/frontend/src/components/TypingIndicator.tsx b/frontend/src/components/TypingIndicator.tsx new file mode 100644 index 0000000..d8a955e --- /dev/null +++ b/frontend/src/components/TypingIndicator.tsx @@ -0,0 +1,40 @@ +import { useChatStore } from '@/stores/chatStore'; +import { useMemo, type FC } from 'react'; + +type TypingIndicatorProps = { + roomId: string; +}; + +export const TypingIndicator: FC = ({ roomId }) => { + const { typingUsers } = useChatStore(); + + const typingUsernames = useMemo(() => { + if (!typingUsers[roomId]) return []; + return Array.from(typingUsers[roomId]); + }, [typingUsers, roomId]); + + if (typingUsernames.length === 0) return null; + + const formatTypingText = () => { + if (typingUsernames.length === 1) { + return `${typingUsernames[0]} is typing`; + } else if (typingUsernames.length === 2) { + return `${typingUsernames[0]} and ${typingUsernames[1]} are typing`; + } else if (typingUsernames.length === 3) { + return `${typingUsernames[0]}, ${typingUsernames[1]} and ${typingUsernames[2]} are typing`; + } else { + return `${typingUsernames.length} people are typing...`; + } + }; + + return ( +
+ {formatTypingText()} + + . + . + . + +
+ ); +}; diff --git a/frontend/src/lib/socket.ts b/frontend/src/lib/socket.ts index 219c35c..4c3f185 100644 --- a/frontend/src/lib/socket.ts +++ b/frontend/src/lib/socket.ts @@ -48,6 +48,8 @@ class SocketService { this.socket.removeAllListeners('error'); this.socket.removeAllListeners('connect'); this.socket.removeAllListeners('disconnect'); + this.socket.removeAllListeners('user_typing'); + this.socket.removeAllListeners('user_stopped_typing'); } getSocket(): Socket | null { @@ -65,6 +67,8 @@ class SocketService { updateMessageReactions, addOnlineUser, removeOnlineUser, + addTypingUser, + removeTypingUser, } = useChatStore.getState(); this.socket.on('connect', () => { @@ -126,6 +130,20 @@ class SocketService { } ); + this.socket.on( + 'user_typing', + (data: { roomId: string; username: string }) => { + addTypingUser(data.roomId, data.username); + } + ); + + this.socket.on( + 'user_stopped_typing', + (data: { roomId: string; username: string }) => { + removeTypingUser(data.roomId, data.username); + } + ); + this.socket.on('error', (data: { message: string }) => { console.error('Socket error:', data.message); toast.error(`Socket error: ${data.message}`, { diff --git a/frontend/src/stores/chatStore.ts b/frontend/src/stores/chatStore.ts index 7c8a8d5..30bdb7b 100644 --- a/frontend/src/stores/chatStore.ts +++ b/frontend/src/stores/chatStore.ts @@ -7,6 +7,7 @@ type ChatState = { selectedRoomId: string | null; messages: Record; onlineUsers: Set; + typingUsers: Record>; setChatRooms: (rooms: ChatRoom[]) => void; addChatRoom: (room: ChatRoom) => void; @@ -20,6 +21,8 @@ type ChatState = { setOnlineUsers: (users: Set) => void; addOnlineUser: (userId: string) => void; removeOnlineUser: (userId: string) => void; + addTypingUser: (roomId: string, username: string) => void; + removeTypingUser: (roomId: string, username: string) => void; }; export const useChatStore = create()( @@ -28,6 +31,7 @@ export const useChatStore = create()( selectedRoomId: null, messages: {}, onlineUsers: new Set(), + typingUsers: {}, setChatRooms: (rooms) => { set({ chatRooms: rooms }); @@ -95,5 +99,38 @@ export const useChatStore = create()( return { onlineUsers: newOnlineUsers }; }); }, + + addTypingUser: (roomId, username) => { + set((state) => { + const roomTypingUsers = state.typingUsers[roomId] ?? new Set(); + roomTypingUsers.add(username); + + return { + typingUsers: { + ...state.typingUsers, + [roomId]: roomTypingUsers, + }, + }; + }); + }, + + removeTypingUser: (roomId, username) => { + set((state) => { + if (!state.typingUsers[roomId]) + return { + typingUsers: state.typingUsers, + }; + + const roomTypingUsers = new Set(state.typingUsers[roomId]); + roomTypingUsers.delete(username); + + return { + typingUsers: { + ...state.typingUsers, + [roomId]: roomTypingUsers, + }, + }; + }); + }, })) );