Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
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
70 changes: 46 additions & 24 deletions backend/src/services/chatServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand All @@ -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();
}
Expand All @@ -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) => {
Expand All @@ -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);
Expand All @@ -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,
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
}, userId); // Exclude sender
}
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: AuthenticatedWebSocket, message: ChatMessage) {
const { roomId, userId, nickname } = message;

if (!roomId || !userId || !nickname) {
Expand All @@ -102,9 +129,9 @@ export class ChatServer {
room.add({ ws, userId, nickname });

// Store connection metadata
(ws as any).roomId = roomId;
(ws as any).userId = userId;
(ws as any).nickname = nickname;
ws.roomId = roomId;
ws.userId = userId;
ws.nickname = nickname;

// Fetch recent messages from database (without profile join - nicknames come from messages)
const { data: messages, error } = await supabase
Expand Down Expand Up @@ -148,10 +175,8 @@ export class ChatServer {
}
}

private handleLeave(ws: WebSocket, message: ChatMessage) {
const roomId = (ws as any).roomId;
const userId = (ws as any).userId;
const nickname = (ws as any).nickname;
private handleLeave(ws: AuthenticatedWebSocket, message: ChatMessage) {
const { roomId, userId, nickname } = ws;

if (roomId && userId) {
const room = this.rooms.get(roomId);
Comment thread
Shreenath-14 marked this conversation as resolved.
Expand All @@ -176,11 +201,9 @@ export class ChatServer {
}
}

private async handleChatMessage(ws: WebSocket, message: ChatMessage) {
private async handleChatMessage(ws: AuthenticatedWebSocket, message: ChatMessage) {
const { content } = message;
const roomId = (ws as any).roomId;
const userId = (ws as any).userId;
const nickname = (ws as any).nickname;
const { roomId, userId, nickname } = ws;

if (!content || !roomId || !userId) {
ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' }));
Expand Down Expand Up @@ -238,9 +261,8 @@ export class ChatServer {
}
}

private handleDisconnect(ws: WebSocket) {
const roomId = (ws as any).roomId;
const userId = (ws as any).userId;
private handleDisconnect(ws: AuthenticatedWebSocket) {
const { roomId, userId } = ws;

if (roomId && userId) {
Comment thread
Shreenath-14 marked this conversation as resolved.
const room = this.rooms.get(roomId);
Expand All @@ -256,13 +278,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
60 changes: 17 additions & 43 deletions backend/src/services/crisisDetection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ async function analyzeWithHuggingFace(
return null;
}

const result: HuggingFaceResponse[][] = await response.json();
const result = (await response.json()) as HuggingFaceResponse[][];
return result[0] || null;
} catch (error) {
console.error('Error calling HuggingFace API:', error);
Expand All @@ -110,49 +110,23 @@ async function analyzeWithHuggingFace(
*/
function analyzeWithKeywords(message: string): CrisisDetectionResult {
const lowerMessage = message.toLowerCase();
const triggeredKeywords: string[] = [];

const foundCritical = CRISIS_KEYWORDS.critical.filter(k => lowerMessage.includes(k));
const foundHigh = CRISIS_KEYWORDS.high.filter(k => lowerMessage.includes(k));
const foundMedium = CRISIS_KEYWORDS.medium.filter(k => lowerMessage.includes(k));
const foundLow = CRISIS_KEYWORDS.low.filter(k => lowerMessage.includes(k));

Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
const triggeredKeywords = [...foundCritical, ...foundHigh, ...foundMedium, ...foundLow];

let highestRiskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical' = 'none';

// Check critical keywords
for (const keyword of CRISIS_KEYWORDS.critical) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
highestRiskLevel = 'critical';
}
}

// Check high-risk keywords
if (highestRiskLevel !== 'critical') {
for (const keyword of CRISIS_KEYWORDS.high) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
if (highestRiskLevel !== 'high') {
highestRiskLevel = 'high';
}
}
}
}

// Check medium-risk keywords
if (highestRiskLevel === 'none' || highestRiskLevel === 'low') {
for (const keyword of CRISIS_KEYWORDS.medium) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
if (highestRiskLevel !== 'medium' && highestRiskLevel !== 'high') {
highestRiskLevel = 'medium';
}
}
}
}

// Check low-risk keywords
if (highestRiskLevel === 'none') {
for (const keyword of CRISIS_KEYWORDS.low) {
if (lowerMessage.includes(keyword)) {
triggeredKeywords.push(keyword);
highestRiskLevel = 'low';
}
}
if (foundCritical.length > 0) {
highestRiskLevel = 'critical';
} else if (foundHigh.length > 0) {
highestRiskLevel = 'high';
} else if (foundMedium.length > 0) {
highestRiskLevel = 'medium';
} else if (foundLow.length > 0) {
highestRiskLevel = 'low';
}
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated

return {
Expand Down
55 changes: 50 additions & 5 deletions frontend/src/components/ChatRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
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<ReturnType<typeof setTimeout>>();

const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({
roomId: room.id,
userId: currentUser.id,
nickname: currentUser.nickname,
Expand All @@ -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);
Comment thread
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());
Comment thread
Shreenath-14 marked this conversation as resolved.
Outdated
};
}, []);

const handleSendMessage = (e: React.FormEvent) => {
e.preventDefault();

Expand Down Expand Up @@ -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>
)}

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
19 changes: 17 additions & 2 deletions 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 All @@ -34,7 +35,7 @@ export function useWebSocket({
const wsRef = useRef<WebSocket | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const reconnectTimeoutRef = useRef<NodeJS.Timeout>();
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
const reconnectAttemptsRef = useRef(0);
const maxReconnectAttempts = 2;

Expand Down Expand Up @@ -124,6 +125,7 @@ export function useWebSocket({
);
}

wsRef.current.onclose = null; // Prevent reconnect loop
wsRef.current.close();
wsRef.current = null;
Comment thread
Shreenath-14 marked this conversation as resolved.
}
Expand Down Expand Up @@ -164,6 +166,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,
isTyping,
})
);
}
},
reconnect: connect,
disconnect,
};
Expand Down
8 changes: 3 additions & 5 deletions frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,11 @@ 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 = {
'Content-Type': 'application/json',
...options.headers,
};
const headers = new Headers(options.headers);
headers.set('Content-Type', 'application/json');

if (session?.access_token) {
headers['Authorization'] = `Bearer ${session.access_token}`;
headers.set('Authorization', `Bearer ${session.access_token}`);
}

const response = await fetch(`${API_BASE_URL}${endpoint}`, {
Expand Down