is typing indicator
This commit is contained in:
parent
92b9760d0a
commit
3814b19838
@ -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={
|
||||||
|
|||||||
@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
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('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}`, {
|
||||||
|
|||||||
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user