is typing indicator

This commit is contained in:
Gal Podlipnik 2025-06-12 13:40:24 +02:00
parent 92b9760d0a
commit 3814b19838
5 changed files with 153 additions and 4 deletions

View File

@ -1,6 +1,14 @@
import { useSocket } from '@/hooks/useSocket'; import { useSocket } from '@/hooks/useSocket';
import { useAuthStore } from '@/stores/authStore';
import { Mic, Paperclip, Send, Smile } from 'lucide-react'; 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 { toast } from 'sonner';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Input } from './ui/input'; import { Input } from './ui/input';
@ -14,8 +22,11 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
const [isRateLimited, setIsRateLimited] = useState(false); const [isRateLimited, setIsRateLimited] = useState(false);
const [showEmojiPicker, setShowEmojiPicker] = useState(false); const [showEmojiPicker, setShowEmojiPicker] = useState(false);
const [isFocused, setIsFocused] = useState(false); const [isFocused, setIsFocused] = useState(false);
const inputRef = useRef<HTMLInputElement>(null); const [isTyping, setIsTyping] = useState(false);
const { socket } = useSocket(); const { socket } = useSocket();
const { user } = useAuthStore();
const inputRef = useRef<HTMLInputElement>(null);
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
useEffect(() => { useEffect(() => {
if (!socket) return; if (!socket) return;
@ -50,16 +61,57 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
content: message.trim(), content: message.trim(),
}); });
if (isTyping && user) {
socket.emit('typing_stop', { roomId, username: user.username });
setIsTyping(false);
}
setMessage(''); setMessage('');
}; };
// Function to handle emoji selection (placeholder for now)
const handleEmojiClick = (emoji: string) => { const handleEmojiClick = (emoji: string) => {
setMessage((prev) => prev + emoji); setMessage((prev) => prev + emoji);
inputRef.current?.focus(); inputRef.current?.focus();
setShowEmojiPicker(false); 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<HTMLInputElement>) => {
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 ( return (
<div className="p-4 border-t border-gray-200 bg-white dark:bg-gray-900 transition-all duration-200"> <div className="p-4 border-t border-gray-200 bg-white dark:bg-gray-900 transition-all duration-200">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
@ -91,7 +143,7 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
<Input <Input
ref={inputRef} ref={inputRef}
value={message} value={message}
onChange={(e) => setMessage(e.target.value)} onChange={handleInputChange}
onFocus={() => setIsFocused(true)} onFocus={() => setIsFocused(true)}
onBlur={() => setIsFocused(false)} onBlur={() => setIsFocused(false)}
placeholder={ placeholder={

View File

@ -4,6 +4,7 @@ import type { Message } from '@/types';
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useEffect, useRef, type FC } from 'react'; import { useEffect, useRef, type FC } from 'react';
import { MessageCard } from './MessageCard'; import { MessageCard } from './MessageCard';
import { TypingIndicator } from './TypingIndicator';
import { ScrollArea } from './ui/scroll-area'; import { ScrollArea } from './ui/scroll-area';
type MessageListProps = { type MessageListProps = {
@ -48,6 +49,7 @@ export const MessageList: FC<MessageListProps> = ({ roomId }) => {
<MessageCard key={message.id} message={message} /> <MessageCard key={message.id} message={message} />
))} ))}
<div ref={messagesEndRef} /> <div ref={messagesEndRef} />
<TypingIndicator roomId={roomId} />
</div> </div>
</ScrollArea> </ScrollArea>
); );

View File

@ -0,0 +1,40 @@
import { useChatStore } from '@/stores/chatStore';
import { useMemo, type FC } from 'react';
type TypingIndicatorProps = {
roomId: string;
};
export const TypingIndicator: FC<TypingIndicatorProps> = ({ 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 (
<div className="text-xs text-gray-500 animate-pulse h-5 px-4">
{formatTypingText()}
<span className="inline-flex">
<span className="animate-bounce">.</span>
<span className="animate-bounce delay-100">.</span>
<span className="animate-bounce delay-200">.</span>
</span>
</div>
);
};

View File

@ -48,6 +48,8 @@ class SocketService {
this.socket.removeAllListeners('error'); this.socket.removeAllListeners('error');
this.socket.removeAllListeners('connect'); this.socket.removeAllListeners('connect');
this.socket.removeAllListeners('disconnect'); this.socket.removeAllListeners('disconnect');
this.socket.removeAllListeners('user_typing');
this.socket.removeAllListeners('user_stopped_typing');
} }
getSocket(): Socket | null { getSocket(): Socket | null {
@ -65,6 +67,8 @@ class SocketService {
updateMessageReactions, updateMessageReactions,
addOnlineUser, addOnlineUser,
removeOnlineUser, removeOnlineUser,
addTypingUser,
removeTypingUser,
} = useChatStore.getState(); } = useChatStore.getState();
this.socket.on('connect', () => { 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 }) => { this.socket.on('error', (data: { message: string }) => {
console.error('Socket error:', data.message); console.error('Socket error:', data.message);
toast.error(`Socket error: ${data.message}`, { toast.error(`Socket error: ${data.message}`, {

View File

@ -7,6 +7,7 @@ type ChatState = {
selectedRoomId: string | null; selectedRoomId: string | null;
messages: Record<string, Message[]>; messages: Record<string, Message[]>;
onlineUsers: Set<string>; onlineUsers: Set<string>;
typingUsers: Record<string, Set<string>>;
setChatRooms: (rooms: ChatRoom[]) => void; setChatRooms: (rooms: ChatRoom[]) => void;
addChatRoom: (room: ChatRoom) => void; addChatRoom: (room: ChatRoom) => void;
@ -20,6 +21,8 @@ type ChatState = {
setOnlineUsers: (users: Set<string>) => void; setOnlineUsers: (users: Set<string>) => void;
addOnlineUser: (userId: string) => void; addOnlineUser: (userId: string) => void;
removeOnlineUser: (userId: string) => void; removeOnlineUser: (userId: string) => void;
addTypingUser: (roomId: string, username: string) => void;
removeTypingUser: (roomId: string, username: string) => void;
}; };
export const useChatStore = create<ChatState>()( export const useChatStore = create<ChatState>()(
@ -28,6 +31,7 @@ export const useChatStore = create<ChatState>()(
selectedRoomId: null, selectedRoomId: null,
messages: {}, messages: {},
onlineUsers: new Set(), onlineUsers: new Set(),
typingUsers: {},
setChatRooms: (rooms) => { setChatRooms: (rooms) => {
set({ chatRooms: rooms }); set({ chatRooms: rooms });
@ -95,5 +99,38 @@ export const useChatStore = create<ChatState>()(
return { onlineUsers: newOnlineUsers }; 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,
},
};
});
},
})) }))
); );