-
-
Notifications
You must be signed in to change notification settings - Fork 22
feat: add typing indicator for better UI experience issue #8 #39
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 2 commits
f47028f
b4b4a8d
b43ac7c
cd1cd9a
c2483dd
87cb3d7
aa771c1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,18 +2,26 @@ import WebSocket from 'ws'; | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { supabase } from '../lib/supabase'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export interface AuthenticatedWebSocket extends WebSocket { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isAlive?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| roomId?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nickname?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface ChatMessage { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'join' | 'leave' | 'chat' | 'crisis_alert'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'typing'; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| roomId?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nickname?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| content?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| riskLevel?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| timestamp?: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isTyping?: boolean; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| interface RoomMember { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws: WebSocket; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws: AuthenticatedWebSocket; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nickname: string; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -30,7 +38,8 @@ export class ChatServer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Heartbeat to detect dead connections | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| setInterval(() => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.wss.clients.forEach((ws: any) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.wss.clients.forEach((client) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ws = client as AuthenticatedWebSocket; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (ws.isAlive === false) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return ws.terminate(); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -40,12 +49,13 @@ export class ChatServer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, 30000); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private handleConnection(ws: WebSocket) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private handleConnection(client: WebSocket) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ws = client as AuthenticatedWebSocket; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| console.log('New WebSocket connection'); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (ws as any).isAlive = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.isAlive = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.on('pong', () => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| (ws as any).isAlive = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.isAlive = true; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.on('message', async (data: string) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -68,7 +78,7 @@ export class ChatServer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private async handleMessage(ws: WebSocket, message: ChatMessage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private async handleMessage(ws: AuthenticatedWebSocket, message: ChatMessage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| switch (message.type) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case 'join': | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.handleJoin(ws, message); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -79,12 +89,29 @@ export class ChatServer { | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case 'chat': | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.handleChatMessage(ws, message); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| case 'typing': | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.handleTyping(ws, message); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| break; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| default: | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private async handleJoin(ws: WebSocket, message: ChatMessage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| private handleTyping(ws: AuthenticatedWebSocket, message: ChatMessage) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const { roomId, userId, nickname } = ws; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (roomId && userId) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| // Broadcast to other users in the room | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.broadcastToRoom(roomId, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'typing', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| userId, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| nickname, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| isTyping: message.isTyping, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, userId); // Exclude sender | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+103
to
+112
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (roomId && userId) { | |
| const isTyping = typeof message.isTyping === 'boolean' ? message.isTyping : false; | |
| // Broadcast to other users in the room | |
| this.broadcastToRoom(roomId, { | |
| type: 'typing', | |
| userId, | |
| nickname, | |
| isTyping, | |
| }, userId); // Exclude sender | |
| } | |
| // Ensure the socket is still a member of the room before broadcasting | |
| if (!roomId || !userId) { | |
| return; | |
| } | |
| const roomMembers = this.rooms.get(roomId); | |
| if (!roomMembers) { | |
| return; | |
| } | |
| const isMember = Array.from(roomMembers).some((member) => member.ws === ws); | |
| if (!isMember) { | |
| return; | |
| } | |
| const isTyping = typeof message.isTyping === 'boolean' ? message.isTyping : false; | |
| // Broadcast to other users in the room | |
| this.broadcastToRoom( | |
| roomId, | |
| { | |
| type: 'typing', | |
| userId, | |
| nickname, | |
| isTyping, | |
| }, | |
| userId, // Exclude sender | |
| ); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -76,13 +76,24 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) | |
| type: 'system', | ||
| }, | ||
| ]); | ||
| } else if (message.type === 'crisis_alert') { | ||
| setShowCrisisAlert(true); | ||
| setTimeout(() => setShowCrisisAlert(false), 10000); | ||
| } else if (message.type === 'typing') { | ||
| const { nickname, isTyping } = message; | ||
| setTypingUsers((prev) => { | ||
| const newSet = new Set(prev); | ||
| if (isTyping) { | ||
| newSet.add(nickname); | ||
|
Shreenath-14 marked this conversation as resolved.
Outdated
|
||
| } else { | ||
| newSet.delete(nickname); | ||
|
Blazzzeee marked this conversation as resolved.
Outdated
|
||
| } | ||
| return newSet; | ||
| }); | ||
| } | ||
|
Comment on lines
78
to
94
|
||
| }, []); | ||
|
|
||
| const { isConnected, connectionError, sendMessage } = useWebSocket({ | ||
| const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set()); | ||
| const typingTimeoutRef = useRef<ReturnType<typeof setTimeout>>(); | ||
|
|
||
| const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({ | ||
| roomId: room.id, | ||
| userId: currentUser.id, | ||
| nickname: currentUser.nickname, | ||
|
|
@@ -95,11 +106,38 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) | |
| }, | ||
| }); | ||
|
|
||
| const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => { | ||
| setInputValue(e.target.value); | ||
|
|
||
| // Debounce typing indicator | ||
| if (typingTimeoutRef.current) { | ||
| clearTimeout(typingTimeoutRef.current); | ||
| } else { | ||
| sendTyping(true); | ||
| } | ||
|
|
||
| typingTimeoutRef.current = setTimeout(() => { | ||
| sendTyping(false); | ||
|
Shreenath-14 marked this conversation as resolved.
|
||
| typingTimeoutRef.current = undefined; | ||
| }, 1000); | ||
| }; | ||
|
|
||
|
|
||
| useEffect(() => { | ||
| // Scroll to bottom when new messages arrive | ||
| messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | ||
| }, [messages]); | ||
|
|
||
| useEffect(() => { | ||
| // Clear typing timeout and reset typing users on unmount | ||
| return () => { | ||
| if (typingTimeoutRef.current) { | ||
| clearTimeout(typingTimeoutRef.current); | ||
| } | ||
| setTypingUsers(new Set()); | ||
|
Shreenath-14 marked this conversation as resolved.
Outdated
|
||
| }; | ||
| }, []); | ||
|
|
||
| const handleSendMessage = (e: React.FormEvent) => { | ||
| e.preventDefault(); | ||
|
|
||
|
|
@@ -248,13 +286,20 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) | |
| <div ref={messagesEndRef} /> | ||
| </div> | ||
|
|
||
| {/* Typing Indicator */} | ||
| {typingUsers.size > 0 && ( | ||
| <div className="px-4 py-2 text-xs text-gray-500 italic bg-gray-50 border-t border-gray-100"> | ||
| {Array.from(typingUsers).join(', ')} {typingUsers.size === 1 ? 'is' : 'are'} typing... | ||
| </div> | ||
| )} | ||
|
|
||
|
Shreenath-14 marked this conversation as resolved.
|
||
| {/* Input */} | ||
| <form onSubmit={handleSendMessage} className="p-4 border-t border-gray-200 bg-white"> | ||
| <div className="flex gap-2"> | ||
| <input | ||
| type="text" | ||
| value={inputValue} | ||
| onChange={(e) => setInputValue(e.target.value)} | ||
| onChange={handleInputChange} | ||
| placeholder={isConnected ? 'Type your message...' : 'Connecting...'} | ||
| disabled={!isConnected} | ||
| className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-gray-100 disabled:cursor-not-allowed" | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.