is typing indicator
This commit is contained in:
parent
92b9760d0a
commit
3814b19838
@ -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<MessageInputProps> = ({ roomId }) => {
|
||||
const [isRateLimited, setIsRateLimited] = useState(false);
|
||||
const [showEmojiPicker, setShowEmojiPicker] = useState(false);
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const { socket } = useSocket();
|
||||
const { user } = useAuthStore();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const typingTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
@ -50,16 +61,57 @@ export const MessageInput: FC<MessageInputProps> = ({ 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<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 (
|
||||
<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">
|
||||
@ -91,7 +143,7 @@ export const MessageInput: FC<MessageInputProps> = ({ roomId }) => {
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={
|
||||
|
||||
@ -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<MessageListProps> = ({ roomId }) => {
|
||||
<MessageCard key={message.id} message={message} />
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
<TypingIndicator roomId={roomId} />
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
40
frontend/src/components/TypingIndicator.tsx
Normal file
40
frontend/src/components/TypingIndicator.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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}`, {
|
||||
|
||||
@ -7,6 +7,7 @@ type ChatState = {
|
||||
selectedRoomId: string | null;
|
||||
messages: Record<string, Message[]>;
|
||||
onlineUsers: Set<string>;
|
||||
typingUsers: Record<string, Set<string>>;
|
||||
|
||||
setChatRooms: (rooms: ChatRoom[]) => void;
|
||||
addChatRoom: (room: ChatRoom) => void;
|
||||
@ -20,6 +21,8 @@ type ChatState = {
|
||||
setOnlineUsers: (users: Set<string>) => 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<ChatState>()(
|
||||
@ -28,6 +31,7 @@ export const useChatStore = create<ChatState>()(
|
||||
selectedRoomId: null,
|
||||
messages: {},
|
||||
onlineUsers: new Set(),
|
||||
typingUsers: {},
|
||||
|
||||
setChatRooms: (rooms) => {
|
||||
set({ chatRooms: rooms });
|
||||
@ -95,5 +99,38 @@ export const useChatStore = create<ChatState>()(
|
||||
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,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
}))
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user