From f47028f91c9cbb23f7ea37d05b183b11f07fea6d Mon Sep 17 00:00:00 2001 From: Shreenath Tambe Date: Wed, 11 Feb 2026 15:50:48 +0530 Subject: [PATCH 1/6] feat: add environment variable validation for backend --- backend/.env.example | 18 ++++-------- backend/package-lock.json | 12 +++++++- backend/package.json | 10 +++++-- backend/src/config/env.ts | 43 +++++++++++++++++++++++++++ backend/src/config/index.ts | 58 +++++++------------------------------ 5 files changed, 78 insertions(+), 63 deletions(-) create mode 100644 backend/src/config/env.ts diff --git a/backend/.env.example b/backend/.env.example index 8fa3f1d..0b99aae 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -1,21 +1,15 @@ -# Backend Environment Variables -# Copy this to .env and fill in your actual values - -# Supabase Configuration (get from https://app.supabase.com → Project Settings → API) +# Supabase Configuration SUPABASE_URL=https://your-project-id.supabase.co -SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... -SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9... +SUPABASE_ANON_KEY=your-anon-key +SUPABASE_SERVICE_ROLE_KEY=your-service-role-key -# HuggingFace API Token (optional - get from https://huggingface.co/settings/tokens) -# If not provided, crisis detection will use keyword-based fallback +# HuggingFace API Token (Optional) HUGGINGFACE_API_TOKEN=hf_YourTokenHere -# Frontend URL (for CORS whitelist) -FRONTEND_URL=http://localhost:3000 - # Server Configuration PORT=3001 +FRONTEND_URL=http://localhost:3000 -# Rate Limiting (optional - defaults shown) +# Rate Limiting RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX_REQUESTS=100 diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e7a659..849b461 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -15,7 +15,8 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "ws": "^8.16.0" + "ws": "^8.16.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", @@ -1900,6 +1901,15 @@ "engines": { "node": ">=6" } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/backend/package.json b/backend/package.json index 1c6f262..652deb1 100644 --- a/backend/package.json +++ b/backend/package.json @@ -9,7 +9,12 @@ "start": "node dist/index.js", "setup-db": "ts-node src/scripts/setupDatabase.ts" }, - "keywords": ["mental-health", "websocket", "express", "crisis-detection"], + "keywords": [ + "mental-health", + "websocket", + "express", + "crisis-detection" + ], "author": "OpenMindWell Contributors", "license": "MIT", "dependencies": { @@ -19,7 +24,8 @@ "express": "^4.18.2", "express-rate-limit": "^7.1.5", "helmet": "^7.1.0", - "ws": "^8.16.0" + "ws": "^8.16.0", + "zod": "^4.3.6" }, "devDependencies": { "@types/cors": "^2.8.17", diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts new file mode 100644 index 0000000..d0ed16d --- /dev/null +++ b/backend/src/config/env.ts @@ -0,0 +1,43 @@ +import { z } from 'zod'; +import dotenv from 'dotenv'; + +dotenv.config(); + +const envSchema = z.object({ + // Supabase Configuration + SUPABASE_URL: z.string().url(), + SUPABASE_ANON_KEY: z.string().min(1), + SUPABASE_SERVICE_ROLE_KEY: z.string().min(1), + + // HuggingFace API Token (Optional) + HUGGINGFACE_API_TOKEN: z.string().optional(), + + // Server Configuration + FRONTEND_URL: z.string().url().default('http://localhost:3000'), + PORT: z.coerce.number().default(3001), + + // Rate Limiting + RATE_LIMIT_WINDOW_MS: z.coerce.number().default(900000), + RATE_LIMIT_MAX_REQUESTS: z.coerce.number().default(100), +}); + +const formatErrors = (errors: z.ZodFormattedError, string>) => { + return Object.entries(errors) + .map(([name, value]) => { + if (value && '_errors' in value) { + return `${name}: ${value._errors.join(', ')}`; + } + return null; + }) + .filter(Boolean); +}; + +const _env = envSchema.safeParse(process.env); + +if (!_env.success) { + console.error('Invalid environment variables:'); + console.error(JSON.stringify(_env.error.format(), null, 4)); + process.exit(1); +} + +export const env = _env.data; diff --git a/backend/src/config/index.ts b/backend/src/config/index.ts index 3337f48..ac5d48f 100644 --- a/backend/src/config/index.ts +++ b/backend/src/config/index.ts @@ -1,60 +1,22 @@ -import dotenv from 'dotenv'; +import { env } from './env'; -dotenv.config(); - -interface Config { +const config = { supabase: { - url: string; - anonKey: string; - serviceRoleKey: string; - }; - huggingface: { - apiToken?: string; - }; - server: { - port: number; - frontendUrl: string; - }; - rateLimit: { - windowMs: number; - maxRequests: number; - }; -} - -const config: Config = { - supabase: { - url: process.env.SUPABASE_URL || '', - anonKey: process.env.SUPABASE_ANON_KEY || '', - serviceRoleKey: process.env.SUPABASE_SERVICE_ROLE_KEY || '', + url: env.SUPABASE_URL, + anonKey: env.SUPABASE_ANON_KEY, + serviceRoleKey: env.SUPABASE_SERVICE_ROLE_KEY, }, huggingface: { - apiToken: process.env.HUGGINGFACE_API_TOKEN, + apiToken: env.HUGGINGFACE_API_TOKEN, }, server: { - port: parseInt(process.env.PORT || '3001', 10), - frontendUrl: process.env.FRONTEND_URL || 'http://localhost:3000', + port: env.PORT, + frontendUrl: env.FRONTEND_URL, }, rateLimit: { - windowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000', 10), - maxRequests: parseInt(process.env.RATE_LIMIT_MAX_REQUESTS || '100', 10), + windowMs: env.RATE_LIMIT_WINDOW_MS, + maxRequests: env.RATE_LIMIT_MAX_REQUESTS, }, }; -// Validation -const requiredEnvVars = [ - 'SUPABASE_URL', - 'SUPABASE_ANON_KEY', - 'SUPABASE_SERVICE_ROLE_KEY', - 'FRONTEND_URL', -]; - -const missingVars = requiredEnvVars.filter((varName) => !process.env[varName]); - -if (missingVars.length > 0) { - throw new Error( - `Missing required environment variables: ${missingVars.join(', ')}\n` + - 'Please check your .env file and ensure all required variables are set.' - ); -} - export default config; From b4b4a8d049ec214c0ca588d369551f02d27a638d Mon Sep 17 00:00:00 2001 From: Shreenath Tambe Date: Wed, 11 Feb 2026 16:11:41 +0530 Subject: [PATCH 2/6] feat: add typing indicator for better UI experience --- backend/src/services/chatServer.ts | 25 ++++++++++++++-- frontend/src/components/ChatRoom.tsx | 44 +++++++++++++++++++++++++--- frontend/src/hooks/useWebSocket.ts | 16 +++++++++- frontend/src/lib/api.ts | 4 +-- 4 files changed, 79 insertions(+), 10 deletions(-) diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index 58127df..6e05041 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -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; @@ -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; + 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 + } + } + private async handleJoin(ws: WebSocket, message: ChatMessage) { const { roomId, userId, nickname } = message; @@ -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); } }); diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 07a1652..5aacd0a 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -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); + } else if (message.type === 'typing') { + const { nickname, isTyping } = message; + setTypingUsers((prev) => { + const newSet = new Set(prev); + if (isTyping) { + newSet.add(nickname); + } else { + newSet.delete(nickname); + } + return newSet; + }); } }, []); - const { isConnected, connectionError, sendMessage } = useWebSocket({ + const [typingUsers, setTypingUsers] = useState>(new Set()); + const typingTimeoutRef = useRef(); + + const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({ roomId: room.id, userId: currentUser.id, nickname: currentUser.nickname, @@ -95,6 +107,23 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) }, }); + const handleInputChange = (e: React.ChangeEvent) => { + setInputValue(e.target.value); + + // Debounce typing indicator + if (typingTimeoutRef.current) { + clearTimeout(typingTimeoutRef.current); + } else { + sendTyping(true); + } + + typingTimeoutRef.current = setTimeout(() => { + sendTyping(false); + typingTimeoutRef.current = undefined; + }, 2000); + }; + + useEffect(() => { // Scroll to bottom when new messages arrive messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); @@ -248,13 +277,20 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
+ {/* Typing Indicator */} + {typingUsers.size > 0 && ( +
+ {Array.from(typingUsers).join(', ')} {typingUsers.size === 1 ? 'is' : 'are'} typing... +
+ )} + {/* Input */}
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" diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 3a4f270..2b1c778 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -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; @@ -10,6 +10,7 @@ interface ChatMessage { timestamp?: string; messages?: any[]; message?: string; + isTyping?: boolean; } interface UseWebSocketOptions { @@ -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', + }) + ); + } + }, reconnect: connect, disconnect, }; diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index fe2cf57..ea89d9a 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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 = { 'Content-Type': 'application/json', - ...options.headers, + ...(options.headers as Record), }; if (session?.access_token) { From b43ac7cdb416e169cddc665cc8700e6c558b3303 Mon Sep 17 00:00:00 2001 From: Ayush Sharma Date: Tue, 17 Feb 2026 21:22:54 +0530 Subject: [PATCH 3/6] Update backend/src/config/env.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- backend/src/config/env.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index d0ed16d..0f56a99 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -21,17 +21,6 @@ const envSchema = z.object({ RATE_LIMIT_MAX_REQUESTS: z.coerce.number().default(100), }); -const formatErrors = (errors: z.ZodFormattedError, string>) => { - return Object.entries(errors) - .map(([name, value]) => { - if (value && '_errors' in value) { - return `${name}: ${value._errors.join(', ')}`; - } - return null; - }) - .filter(Boolean); -}; - const _env = envSchema.safeParse(process.env); if (!_env.success) { From cd1cd9a375432e0ecfbeef668aa217c00d6ea514 Mon Sep 17 00:00:00 2001 From: Shreenath Tambe Date: Mon, 2 Mar 2026 17:37:36 +0530 Subject: [PATCH 4/6] refactor: address PR feedback and fix type safety issues --- backend/src/services/chatServer.ts | 55 ++++++++++++----------- backend/src/services/crisisDetection.ts | 60 +++++++------------------ frontend/src/components/ChatRoom.tsx | 15 +++++-- frontend/src/hooks/useWebSocket.ts | 5 ++- frontend/src/lib/api.ts | 8 ++-- 5 files changed, 64 insertions(+), 79 deletions(-) diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index 6e05041..b1ad2c9 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -2,6 +2,13 @@ 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' | 'typing'; roomId?: string; @@ -10,10 +17,11 @@ interface ChatMessage { 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); @@ -87,10 +97,8 @@ export class ChatServer { } } - private handleTyping(ws: WebSocket, message: ChatMessage) { - const roomId = (ws as any).roomId; - const userId = (ws as any).userId; - const nickname = (ws as any).nickname; + private handleTyping(ws: AuthenticatedWebSocket, message: ChatMessage) { + const { roomId, userId, nickname } = ws; if (roomId && userId) { // Broadcast to other users in the room @@ -98,12 +106,12 @@ export class ChatServer { type: 'typing', userId, nickname, - isTyping: message.content === 'true', // Use content field to convey on/off state if needed, or just presence of event + isTyping: message.isTyping, }, userId); // Exclude sender } } - private async handleJoin(ws: WebSocket, message: ChatMessage) { + private async handleJoin(ws: AuthenticatedWebSocket, message: ChatMessage) { const { roomId, userId, nickname } = message; if (!roomId || !userId || !nickname) { @@ -121,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 @@ -167,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); @@ -195,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' })); @@ -257,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) { const room = this.rooms.get(roomId); diff --git a/backend/src/services/crisisDetection.ts b/backend/src/services/crisisDetection.ts index 796bab2..4bff63f 100644 --- a/backend/src/services/crisisDetection.ts +++ b/backend/src/services/crisisDetection.ts @@ -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); @@ -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)); + + 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'; } return { diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 5aacd0a..9d67e63 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -76,7 +76,6 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) type: 'system', }, ]); - setTimeout(() => setShowCrisisAlert(false), 10000); } else if (message.type === 'typing') { const { nickname, isTyping } = message; setTypingUsers((prev) => { @@ -92,7 +91,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) }, []); const [typingUsers, setTypingUsers] = useState>(new Set()); - const typingTimeoutRef = useRef(); + const typingTimeoutRef = useRef>(); const { isConnected, connectionError, sendMessage, sendTyping } = useWebSocket({ roomId: room.id, @@ -120,7 +119,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) typingTimeoutRef.current = setTimeout(() => { sendTyping(false); typingTimeoutRef.current = undefined; - }, 2000); + }, 1000); }; @@ -129,6 +128,16 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) 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()); + }; + }, []); + const handleSendMessage = (e: React.FormEvent) => { e.preventDefault(); diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 2b1c778..da3d06c 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -35,7 +35,7 @@ export function useWebSocket({ const wsRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); - const reconnectTimeoutRef = useRef(); + const reconnectTimeoutRef = useRef>(); const reconnectAttemptsRef = useRef(0); const maxReconnectAttempts = 2; @@ -125,6 +125,7 @@ export function useWebSocket({ ); } + wsRef.current.onclose = null; // Prevent reconnect loop wsRef.current.close(); wsRef.current = null; } @@ -173,7 +174,7 @@ export function useWebSocket({ roomId, userId, nickname, - content: isTyping ? 'true' : 'false', + isTyping, }) ); } diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index ea89d9a..8c7ef29 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -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: Record = { - 'Content-Type': 'application/json', - ...(options.headers as Record), - }; + 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}`, { From 87cb3d7171052b4d0ee30ad03aaffaeabd99f8ff Mon Sep 17 00:00:00 2001 From: Shreenath Tambe Date: Wed, 4 Mar 2026 01:56:37 +0530 Subject: [PATCH 5/6] refactor: manual cleanup and specific PR fixes --- backend/src/services/chatServer.ts | 19 ++++++++++++++- backend/src/services/crisisDetection.ts | 32 ++++++++++++------------- frontend/src/components/ChatRoom.tsx | 6 +++-- frontend/src/hooks/useWebSocket.ts | 8 ++++--- 4 files changed, 43 insertions(+), 22 deletions(-) diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index b1ad2c9..db029dd 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -101,12 +101,13 @@ export class ChatServer { const { roomId, userId, nickname } = ws; 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: message.isTyping, + isTyping, }, userId); // Exclude sender } } @@ -195,6 +196,14 @@ export class ChatServer { nickname, timestamp: new Date().toISOString(), }); + + // Clear typing indicator for leaving user + this.broadcastToRoom(roomId, { + type: 'typing', + userId, + nickname, + isTyping: false, + }); console.log(`${nickname} left room ${roomId}`); } @@ -272,6 +281,14 @@ export class ChatServer { room.delete(member); } }); + + // Clear typing indicator for disconnected user + this.broadcastToRoom(roomId, { + type: 'typing', + userId, + nickname: ws.nickname, + isTyping: false, + }); } } diff --git a/backend/src/services/crisisDetection.ts b/backend/src/services/crisisDetection.ts index 4bff63f..0bca547 100644 --- a/backend/src/services/crisisDetection.ts +++ b/backend/src/services/crisisDetection.ts @@ -110,23 +110,23 @@ async function analyzeWithHuggingFace( */ function analyzeWithKeywords(message: string): CrisisDetectionResult { const lowerMessage = message.toLowerCase(); - - 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)); - - const triggeredKeywords = [...foundCritical, ...foundHigh, ...foundMedium, ...foundLow]; - let highestRiskLevel: 'none' | 'low' | 'medium' | 'high' | 'critical' = 'none'; - 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'; + const triggeredKeywords: string[] = []; + + const riskMap = { + critical: CRISIS_KEYWORDS.critical, + high: CRISIS_KEYWORDS.high, + medium: CRISIS_KEYWORDS.medium, + low: CRISIS_KEYWORDS.low, + } as const; + + for (const [level, keywords] of Object.entries(riskMap)) { + const found = keywords.filter((k) => lowerMessage.includes(k)); + if (found.length > 0) { + triggeredKeywords.push(...found); + highestRiskLevel = level as typeof highestRiskLevel; + break; + } } return { diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 9d67e63..8ecf6fb 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -24,6 +24,8 @@ interface ChatRoomProps { onClose: () => void; } +const EMPTY_SET = new Set(); + export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) { const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); @@ -119,7 +121,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) typingTimeoutRef.current = setTimeout(() => { sendTyping(false); typingTimeoutRef.current = undefined; - }, 1000); + }, 2000); }; @@ -134,7 +136,7 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) if (typingTimeoutRef.current) { clearTimeout(typingTimeoutRef.current); } - setTypingUsers(new Set()); + setTypingUsers(prev => prev.size > 0 ? EMPTY_SET : prev); }; }, []); diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index da3d06c..6f7680e 100644 --- a/frontend/src/hooks/useWebSocket.ts +++ b/frontend/src/hooks/useWebSocket.ts @@ -37,9 +37,11 @@ export function useWebSocket({ const [connectionError, setConnectionError] = useState(null); const reconnectTimeoutRef = useRef>(); const reconnectAttemptsRef = useRef(0); + const isManualCloseRef = useRef(false); const maxReconnectAttempts = 2; const connect = useCallback(() => { + isManualCloseRef.current = false; if (wsRef.current?.readyState === WebSocket.OPEN) { return; } @@ -88,7 +90,7 @@ export function useWebSocket({ onDisconnect?.(); // Attempt to reconnect with longer delays - if (reconnectAttemptsRef.current < maxReconnectAttempts) { + if (!isManualCloseRef.current && reconnectAttemptsRef.current < maxReconnectAttempts) { reconnectAttemptsRef.current += 1; const delay = Math.min(3000 * Math.pow(2, reconnectAttemptsRef.current), 10000); console.log(`Reconnecting in ${delay}ms (attempt ${reconnectAttemptsRef.current})`); @@ -96,7 +98,7 @@ export function useWebSocket({ reconnectTimeoutRef.current = setTimeout(() => { connect(); }, delay); - } else { + } else if (!isManualCloseRef.current) { setConnectionError('Connection lost. Please refresh to reconnect.'); } }; @@ -125,7 +127,7 @@ export function useWebSocket({ ); } - wsRef.current.onclose = null; // Prevent reconnect loop + isManualCloseRef.current = true; wsRef.current.close(); wsRef.current = null; } From aa771c146c00fa77cdbd36d3da3ae1fa29c4a82f Mon Sep 17 00:00:00 2001 From: Maanik Khurana <148184733+Blazzzeee@users.noreply.github.com> Date: Sat, 7 Mar 2026 22:11:04 +0530 Subject: [PATCH 6/6] Update frontend/src/components/ChatRoom.tsx minor: change setkey to userid from nickname (duplication fix) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frontend/src/components/ChatRoom.tsx | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 8ecf6fb..b3bdaa3 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -79,13 +79,15 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) }, ]); } else if (message.type === 'typing') { - const { nickname, isTyping } = message; + const { userId, isTyping } = message; setTypingUsers((prev) => { const newSet = new Set(prev); - if (isTyping) { - newSet.add(nickname); - } else { - newSet.delete(nickname); + if (userId) { + if (isTyping) { + newSet.add(userId); + } else { + newSet.delete(userId); + } } return newSet; });