Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 22 additions & 3 deletions backend/src/services/chatServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { supabase } from '../lib/supabase';
import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection';

interface ChatMessage {
type: 'join' | 'leave' | 'chat' | 'crisis_alert';
type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'typing';
roomId?: string;
userId?: string;
nickname?: string;
Expand Down Expand Up @@ -79,11 +79,30 @@ 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 handleTyping(ws: WebSocket, message: ChatMessage) {
const roomId = (ws as any).roomId;
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
const userId = (ws as any).userId;
const nickname = (ws as any).nickname;

if (roomId && userId) {
// Broadcast to other users in the room
this.broadcastToRoom(roomId, {
type: 'typing',
userId,
nickname,
isTyping: message.content === 'true', // Use content field to convey on/off state if needed, or just presence of event
}, userId); // Exclude sender
}
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
Comment on lines +103 to +112
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

handleTyping trusts ws.roomId/userId metadata without verifying the socket is still a member of that room. Because handleLeave removes the member from the room set but doesn’t clear ws.roomId/userId, a client that sends leave (but keeps the connection open) can still broadcast typing events to the room. Consider either clearing ws.roomId/ws.userId/ws.nickname on leave, and/or checking membership in this.rooms.get(roomId) before broadcasting.

Suggested change
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
);

Copilot uses AI. Check for mistakes.
}

private async handleJoin(ws: WebSocket, message: ChatMessage) {
const { roomId, userId, nickname } = message;

Expand Down Expand Up @@ -256,13 +275,13 @@ export class ChatServer {
console.log('WebSocket disconnected');
}

private broadcastToRoom(roomId: string, message: any) {
private broadcastToRoom(roomId: string, message: any, excludeUserId?: string) {
const room = this.rooms.get(roomId);
if (!room) return;

const messageStr = JSON.stringify(message);
room.forEach((member) => {
if (member.ws.readyState === WebSocket.OPEN) {
if (member.userId !== excludeUserId && member.ws.readyState === WebSocket.OPEN) {
member.ws.send(messageStr);
}
});
Expand Down
44 changes: 40 additions & 4 deletions frontend/src/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,25 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
type: 'system',
},
]);
} else if (message.type === 'crisis_alert') {
setShowCrisisAlert(true);
setTimeout(() => setShowCrisisAlert(false), 10000);
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
} else if (message.type === 'typing') {
const { nickname, isTyping } = message;
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
setTypingUsers((prev) => {
const newSet = new Set(prev);
if (isTyping) {
newSet.add(nickname);
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
} else {
newSet.delete(nickname);
Comment thread
Blazzzeee marked this conversation as resolved.
Outdated
}
return newSet;
});
}
Comment on lines 78 to 94
Copy link

Copilot AI Mar 6, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useWebSocket / backend can send a crisis_alert message (with a message field for resources), but this handler doesn’t currently handle message.type === 'crisis_alert'. That means the sender won’t see the crisis resources payload. Add a crisis_alert branch (or remove/replace the backend event) so the message type is either rendered or explicitly ignored with a clear UX decision.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ignore for now

}, []);

const { isConnected, connectionError, sendMessage } = useWebSocket({
const [typingUsers, setTypingUsers] = useState<Set<string>>(new Set());
const typingTimeoutRef = useRef<NodeJS.Timeout>();
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated

const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({
roomId: room.id,
userId: currentUser.id,
nickname: currentUser.nickname,
Expand All @@ -95,6 +107,23 @@ 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);
Comment thread
Shreenath-14 marked this conversation as resolved.
typingTimeoutRef.current = undefined;
}, 2000);
Comment thread
Shreenath-14 marked this conversation as resolved.
};


useEffect(() => {
// Scroll to bottom when new messages arrive
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
Expand Down Expand Up @@ -248,13 +277,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>
)}

Comment thread
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"
Expand Down
16 changes: 15 additions & 1 deletion frontend/src/hooks/useWebSocket.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState, useCallback } from 'react';

interface ChatMessage {
type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'history' | 'error';
type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'history' | 'error' | 'typing';
roomId?: string;
userId?: string;
nickname?: string;
Expand All @@ -10,6 +10,7 @@ interface ChatMessage {
timestamp?: string;
messages?: any[];
message?: string;
isTyping?: boolean;
}

interface UseWebSocketOptions {
Expand Down Expand Up @@ -164,6 +165,19 @@ export function useWebSocket({
isConnected,
connectionError,
sendMessage,
sendTyping: (isTyping: boolean) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: 'typing',
roomId,
userId,
nickname,
content: isTyping ? 'true' : 'false',
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
})
);
}
},
reconnect: connect,
disconnect,
};
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001
async function apiFetch(endpoint: string, options: RequestInit = {}) {
const { data: { session } } = await (await import('./supabase')).supabase.auth.getSession();

const headers: HeadersInit = {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...options.headers,
...(options.headers as Record<string, string>),
};
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated

if (session?.access_token) {
Expand Down