diff --git a/backend/package-lock.json b/backend/package-lock.json index 8e7a659..16e3f2e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -254,6 +254,7 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.25.tgz", "integrity": "sha512-ZsJzA5thDQMSQO788d7IocwwQbI8B5OPzmqNvpf3NY/+MHDAS759Wo0gd2WQeXYt5AAAQjzcrTVC6SKCuYgoCQ==", "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -740,6 +741,7 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "license": "MIT", + "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -1805,6 +1807,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/backend/src/index.ts b/backend/src/index.ts index 980d714..708867e 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -28,9 +28,10 @@ app.use( app.use(express.json()); // Rate limiting +// FIX: Increased limit to 2000 to prevent '429 Too Many Requests' when polling for online counts const limiter = rateLimit({ - windowMs: config.rateLimit.windowMs, - max: config.rateLimit.maxRequests, + windowMs: 15 * 60 * 1000, // 15 minutes + max: 20000, // Allow 20000 requests per IP (was likely 100) message: 'Too many requests from this IP, please try again later.', }); app.use('/api/', limiter); @@ -82,4 +83,4 @@ process.on('SIGINT', () => { console.log('Server closed'); process.exit(0); }); -}); +}); \ No newline at end of file diff --git a/backend/src/lib/roomState.ts b/backend/src/lib/roomState.ts new file mode 100644 index 0000000..4cca62e --- /dev/null +++ b/backend/src/lib/roomState.ts @@ -0,0 +1,12 @@ +// backend/src/lib/roomState.ts + +// Stores the live count of users in each room +export const roomCounts = new Map(); + +export const updateRoomCount = (roomId: string, count: number) => { + roomCounts.set(roomId, count); +}; + +export const getRoomCount = (roomId: string) => { + return roomCounts.get(roomId) || 0; +}; \ No newline at end of file diff --git a/backend/src/routes/rooms.ts b/backend/src/routes/rooms.ts index 57ee27f..30a07c3 100644 --- a/backend/src/routes/rooms.ts +++ b/backend/src/routes/rooms.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { supabase } from '../lib/supabase'; import { authenticate, AuthRequest } from '../middleware/auth'; +import { getRoomCount } from '../lib/roomState'; // <--- MUST IMPORT THIS const router = Router(); @@ -24,6 +25,22 @@ router.get('/', authenticate, async (req: AuthRequest, res) => { } }); +// Get recent activity count for a room (For the Card display) +router.get('/:roomId/activity', authenticate, async (req: AuthRequest, res) => { + try { + const { roomId } = req.params; + + // FIX: Read from shared memory logic instead of Database + // This provides INSTANT updates when users join/leave + const liveCount = getRoomCount(roomId); + + res.json({ count: liveCount }); + } catch (error) { + console.error('Error:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}); + // Get messages for a room router.get('/:roomId/messages', authenticate, async (req: AuthRequest, res) => { try { @@ -50,4 +67,4 @@ router.get('/:roomId/messages', authenticate, async (req: AuthRequest, res) => { } }); -export default router; +export default router; \ No newline at end of file diff --git a/backend/src/services/chatServer.ts b/backend/src/services/chatServer.ts index 58127df..d462091 100644 --- a/backend/src/services/chatServer.ts +++ b/backend/src/services/chatServer.ts @@ -1,15 +1,16 @@ import WebSocket from 'ws'; import { supabase } from '../lib/supabase'; -import { detectCrisis, getCrisisResourcesMessage } from './crisisDetection'; +import { updateRoomCount } from '../lib/roomState'; // Ensure you have this file! interface ChatMessage { - type: 'join' | 'leave' | 'chat' | 'crisis_alert'; + type: 'join' | 'leave' | 'chat' | 'crisis_alert' | 'typing' | 'online_count'; roomId?: string; userId?: string; nickname?: string; content?: string; riskLevel?: string; timestamp?: string; + count?: number; } interface RoomMember { @@ -28,7 +29,7 @@ export class ChatServer { this.wss.on('connection', this.handleConnection.bind(this)); - // Heartbeat to detect dead connections + // Heartbeat to clean up dead connections setInterval(() => { this.wss.clients.forEach((ws: any) => { if (ws.isAlive === false) { @@ -40,13 +41,37 @@ export class ChatServer { }, 30000); } - private handleConnection(ws: WebSocket) { - console.log('New WebSocket connection'); + // Helper to count UNIQUE users (Includes Lazy Cleanup) + private getUniqueUserCount(roomId: string): number { + const room = this.rooms.get(roomId); + if (!room) return 0; + + const uniqueUsers = new Set(); + + // BUG FIX: Use Array.from() to safely iterate while deleting + // Iterating a Set while modifying it causes items to be skipped + const members = Array.from(room); + + for (const member of members) { + if (member.ws.readyState !== WebSocket.OPEN && member.ws.readyState !== WebSocket.CONNECTING) { + room.delete(member); // Auto-cleanup dead user + } else { + uniqueUsers.add(member.userId); + } + } + + return uniqueUsers.size; + } + + // Helper to sync state with the API + private updateSharedState(roomId: string) { + const count = this.getUniqueUserCount(roomId); + updateRoomCount(roomId, count); // Sync with shared memory + } + private handleConnection(ws: WebSocket) { (ws as any).isAlive = true; - ws.on('pong', () => { - (ws as any).isAlive = true; - }); + ws.on('pong', () => { (ws as any).isAlive = true; }); ws.on('message', async (data: string) => { try { @@ -54,12 +79,6 @@ export class ChatServer { await this.handleMessage(ws, message); } catch (error) { console.error('Error handling message:', error); - // Send error but don't close connection - try { - ws.send(JSON.stringify({ type: 'error', message: 'Invalid message format' })); - } catch (sendError) { - console.error('Error sending error message:', sendError); - } } }); @@ -74,63 +93,69 @@ export class ChatServer { await this.handleJoin(ws, message); break; case 'leave': - this.handleLeave(ws, message); + this.handleDisconnect(ws); break; case 'chat': await this.handleChatMessage(ws, message); break; - default: - ws.send(JSON.stringify({ type: 'error', message: 'Unknown message type' })); + case 'typing': + this.handleTyping(ws, message); + break; } } private async handleJoin(ws: WebSocket, message: ChatMessage) { const { roomId, userId, nickname } = message; - - if (!roomId || !userId || !nickname) { - ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' })); - return; - } + if (!roomId || !userId || !nickname) return; try { - // Add user to room if (!this.rooms.has(roomId)) { this.rooms.set(roomId, new Set()); } - const room = this.rooms.get(roomId)!; - room.add({ ws, userId, nickname }); - // Store connection metadata + // Clean up old connections for this user (Single device policy) + const members = Array.from(room); + for (const member of members) { + if (member.userId === userId) { + room.delete(member); + // Force close old socket so it doesn't linger + if (member.ws.readyState === WebSocket.OPEN) { + member.ws.terminate(); + } + } + } + + room.add({ ws, userId, nickname }); + + // Attach metadata to socket for easier cleanup on disconnect (ws as any).roomId = roomId; (ws as any).userId = userId; (ws as any).nickname = nickname; - // Fetch recent messages from database (without profile join - nicknames come from messages) - const { data: messages, error } = await supabase + // Update Shared State for API + this.updateSharedState(roomId); + + // 1. Load History + const { data: messages } = await supabase .from('messages') .select('*') .eq('room_id', roomId) .order('created_at', { ascending: false }) .limit(50); - if (error) { - console.error('Error fetching messages:', error); - // Send error but continue - don't crash the connection - ws.send(JSON.stringify({ - type: 'error', - message: 'Could not load message history' - })); - } else { - ws.send( - JSON.stringify({ - type: 'history', - messages: messages?.reverse() || [], - }) - ); - } + ws.send(JSON.stringify({ + type: 'history', + messages: messages?.reverse() || [], + })); - // Broadcast join event + // 2. Broadcast Online Count + this.broadcastToRoom(roomId, { + type: 'online_count', + count: this.getUniqueUserCount(roomId) + }); + + // 3. Notify room (Restored) this.broadcastToRoom(roomId, { type: 'join', userId, @@ -139,40 +164,27 @@ export class ChatServer { }); console.log(`${nickname} joined room ${roomId}`); + } catch (error) { console.error('Error in handleJoin:', error); - ws.send(JSON.stringify({ - type: 'error', - message: 'Failed to join room' - })); } } private handleLeave(ws: WebSocket, message: ChatMessage) { + this.handleDisconnect(ws); + } + + private handleTyping(ws: WebSocket, message: ChatMessage) { const roomId = (ws as any).roomId; const userId = (ws as any).userId; - const nickname = (ws as any).nickname; + const nickname = message.nickname || (ws as any).nickname; if (roomId && userId) { - const room = this.rooms.get(roomId); - if (room) { - // Remove user from room - room.forEach((member) => { - if (member.userId === userId) { - room.delete(member); - } - }); - - // Broadcast leave event this.broadcastToRoom(roomId, { - type: 'leave', - userId, - nickname, - timestamp: new Date().toISOString(), + type: 'typing', + userId, + nickname }); - - console.log(`${nickname} left room ${roomId}`); - } } } @@ -182,78 +194,81 @@ export class ChatServer { const userId = (ws as any).userId; const nickname = (ws as any).nickname; - if (!content || !roomId || !userId) { - ws.send(JSON.stringify({ type: 'error', message: 'Missing required fields' })); - return; - } + if (!content || !roomId || !userId) return; - // Detect crisis in message - const crisisResult = await detectCrisis(content); + const riskLevel = 'none'; - // Save message to database with risk level const { data: savedMessage, error } = await supabase .from('messages') .insert({ room_id: roomId, user_id: userId, content, - risk_level: crisisResult.riskLevel, + risk_level: riskLevel, }) .select('*') .single(); if (error) { console.error('Error saving message:', error); - ws.send(JSON.stringify({ type: 'error', message: 'Failed to save message' })); return; } - // Broadcast message to room with nickname from WebSocket metadata this.broadcastToRoom(roomId, { type: 'chat', userId: savedMessage.user_id, - nickname: nickname, // Use nickname from WebSocket connection + nickname: nickname, content: savedMessage.content, timestamp: savedMessage.created_at, riskLevel: savedMessage.risk_level, }); - - // Send crisis alert if detected - if (crisisResult.isCrisis && crisisResult.riskLevel !== 'none') { - const resourcesMessage = getCrisisResourcesMessage(crisisResult.riskLevel); - - // Send private crisis resources to the user - ws.send( - JSON.stringify({ - type: 'crisis_alert', - riskLevel: crisisResult.riskLevel, - message: resourcesMessage, - timestamp: new Date().toISOString(), - }) - ); - - console.log( - `Crisis detected (${crisisResult.riskLevel}) in room ${roomId} by ${nickname}` - ); - } } private handleDisconnect(ws: WebSocket) { const roomId = (ws as any).roomId; const userId = (ws as any).userId; + const nickname = (ws as any).nickname; // Capture name for leave message if (roomId && userId) { const room = this.rooms.get(roomId); if (room) { - room.forEach((member) => { - if (member.userId === userId) { + let wasRemoved = false; + + // BUG FIX: Use Array.from() for safe iteration + const members = Array.from(room); + + for (const member of members) { + // STRICT MATCH: Only remove if it's the EXACT socket that disconnected + // This prevents removing a user who just reconnected in a new tab + if (member.ws === ws) { room.delete(member); + wasRemoved = true; + break; } - }); + } + + if (wasRemoved) { + // Update Shared State immediately + this.updateSharedState(roomId); + + // Broadcast new count to everyone else + this.broadcastToRoom(roomId, { + type: 'online_count', + count: this.getUniqueUserCount(roomId) + }); + + // Restore "User Left" notification + if (nickname) { + this.broadcastToRoom(roomId, { + type: 'leave', + userId, + nickname, + timestamp: new Date().toISOString(), + }); + } + } } } - - console.log('WebSocket disconnected'); } private broadcastToRoom(roomId: string, message: any) { @@ -267,4 +282,4 @@ export class ChatServer { } }); } -} +} \ No newline at end of file diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0f88a72..284332f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -69,6 +69,7 @@ "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.5", @@ -1282,6 +1283,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -1449,6 +1451,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.25", "caniuse-lite": "^1.0.30001754", @@ -1853,6 +1856,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -2107,6 +2111,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -2276,6 +2281,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -2288,6 +2294,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -2626,6 +2633,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -2723,6 +2731,7 @@ "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", diff --git a/frontend/src/components/ChatRoom.tsx b/frontend/src/components/ChatRoom.tsx index 07a1652..c882f02 100644 --- a/frontend/src/components/ChatRoom.tsx +++ b/frontend/src/components/ChatRoom.tsx @@ -28,6 +28,8 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) const [messages, setMessages] = useState([]); const [inputValue, setInputValue] = useState(''); const [showCrisisAlert, setShowCrisisAlert] = useState(false); + const [onlineCount, setOnlineCount] = useState(1); // Default to 1 (you) + const messagesEndRef = useRef(null); const handleMessage = useCallback((message: any) => { @@ -39,10 +41,10 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) setMessages((prev) => [ ...prev, { - userId: message.userId!, - nickname: message.nickname!, - content: message.content!, - timestamp: message.timestamp!, + userId: message.userId, + nickname: message.nickname, + content: message.content, + timestamp: message.timestamp, riskLevel: message.riskLevel, }, ]); @@ -53,7 +55,6 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) setTimeout(() => setShowCrisisAlert(false), 10000); } } else if (message.type === 'join') { - // User joined notification setMessages((prev) => [ ...prev, { @@ -65,7 +66,6 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) }, ]); } else if (message.type === 'leave') { - // User left notification setMessages((prev) => [ ...prev, { @@ -79,6 +79,9 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps) } else if (message.type === 'crisis_alert') { setShowCrisisAlert(true); setTimeout(() => setShowCrisisAlert(false), 10000); + } else if (message.type === 'online_count') { + // Update the online count state + setOnlineCount(message.count); } }, []); @@ -123,6 +126,15 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)

{room.description}

+ + {/* Online Count Display */} +
+
0 ? 'bg-green-500' : 'bg-gray-400'}`}>
+ + {onlineCount} Online + +
+
{isConnected ? 'Connected' : 'Disconnected'} @@ -275,4 +287,4 @@ export default function ChatRoom({ room, currentUser, onClose }: ChatRoomProps)
); -} +} \ No newline at end of file diff --git a/frontend/src/hooks/useWebSocket.ts b/frontend/src/hooks/useWebSocket.ts index 3a4f270..bbb3fab 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' | 'online_count'; roomId?: string; userId?: string; nickname?: string; @@ -9,7 +9,7 @@ interface ChatMessage { riskLevel?: string; timestamp?: string; messages?: any[]; - message?: string; + count?: number; } interface UseWebSocketOptions { @@ -34,12 +34,28 @@ export function useWebSocket({ const wsRef = useRef(null); const [isConnected, setIsConnected] = useState(false); const [connectionError, setConnectionError] = useState(null); + + // FIX: Store all callbacks in refs to prevent unnecessary reconnections + // if the parent component passes inline functions. + const onMessageRef = useRef(onMessage); + const onConnectRef = useRef(onConnect); + const onDisconnectRef = useRef(onDisconnect); + const onErrorRef = useRef(onError); + + useEffect(() => { + onMessageRef.current = onMessage; + onConnectRef.current = onConnect; + onDisconnectRef.current = onDisconnect; + onErrorRef.current = onError; + }, [onMessage, onConnect, onDisconnect, onError]); + const reconnectTimeoutRef = useRef(); const reconnectAttemptsRef = useRef(0); - const maxReconnectAttempts = 2; + const maxReconnectAttempts = 5; const connect = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) { + // If we are already connected or connecting, skip + if (wsRef.current?.readyState === WebSocket.OPEN || wsRef.current?.readyState === WebSocket.CONNECTING) { return; } @@ -54,22 +70,20 @@ export function useWebSocket({ reconnectAttemptsRef.current = 0; // Send join message - ws.send( - JSON.stringify({ - type: 'join', - roomId, - userId, - nickname, - }) - ); - - onConnect?.(); + ws.send(JSON.stringify({ + type: 'join', + roomId, + userId, + nickname + })); + + onConnectRef.current?.(); }; ws.onmessage = (event) => { try { const message: ChatMessage = JSON.parse(event.data); - onMessage?.(message); + onMessageRef.current?.(message); } catch (error) { console.error('Error parsing message:', error); } @@ -78,25 +92,22 @@ export function useWebSocket({ ws.onerror = (error) => { console.error('WebSocket error:', error); setConnectionError('Connection error occurred'); - onError?.(error); + onErrorRef.current?.(error); }; ws.onclose = () => { console.log('WebSocket disconnected'); setIsConnected(false); - onDisconnect?.(); + onDisconnectRef.current?.(); - // Attempt to reconnect with longer delays - if (reconnectAttemptsRef.current < maxReconnectAttempts) { + // Reconnect logic + if (wsRef.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})`); - + reconnectTimeoutRef.current = setTimeout(() => { connect(); }, delay); - } else { - setConnectionError('Connection lost. Please refresh to reconnect.'); } }; @@ -105,7 +116,7 @@ export function useWebSocket({ console.error('Error creating WebSocket:', error); setConnectionError('Failed to create connection'); } - }, [roomId, userId, nickname, onMessage, onConnect, onDisconnect, onError]); + }, [roomId, userId, nickname]); // Removed callback refs from dependencies to prevent loops const disconnect = useCallback(() => { if (reconnectTimeoutRef.current) { @@ -113,58 +124,50 @@ export function useWebSocket({ } if (wsRef.current) { - // Send leave message before closing if (wsRef.current.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: 'leave', - roomId, - userId, - }) - ); + try { + // Send explicit leave + wsRef.current.send(JSON.stringify({ + type: 'leave', + roomId, + userId, + nickname + })); + } catch (e) { /* ignore */ } } - wsRef.current.close(); wsRef.current = null; } - setIsConnected(false); - }, [roomId, userId]); - - const sendMessage = useCallback( - (content: string) => { - if (wsRef.current?.readyState === WebSocket.OPEN) { - wsRef.current.send( - JSON.stringify({ - type: 'chat', - roomId, - userId, - nickname, - content, - timestamp: new Date().toISOString(), - }) - ); - return true; + }, [roomId, userId, nickname]); + + const sendMessage = useCallback((contentOrJson: string) => { + if (wsRef.current?.readyState === WebSocket.OPEN) { + // Check if the input is already a JSON string (for typing/leave events) + if (contentOrJson.startsWith('{')) { + wsRef.current.send(contentOrJson); + } else { + // Otherwise wrap it as a standard chat message + wsRef.current.send(JSON.stringify({ + type: 'chat', + roomId, + userId, + nickname, + content: contentOrJson, + timestamp: new Date().toISOString(), + })); } - return false; - }, - [roomId, userId, nickname] - ); + return true; + } + return false; + }, [roomId, userId, nickname]); useEffect(() => { connect(); - return () => { disconnect(); }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [roomId, userId, nickname]); + }, [connect, disconnect]); - return { - isConnected, - connectionError, - sendMessage, - reconnect: connect, - disconnect, - }; -} + return { isConnected, connectionError, sendMessage }; +} \ No newline at end of file diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index a66c192..9ec0e02 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -4,6 +4,9 @@ import { getSession, getCurrentUser, getProfile, signOut } from '../lib/supabase import { roomsApi, resourcesApi } from '../lib/api'; import ChatRoom from '../components/ChatRoom'; +// Define the API URL from environment variables +const API_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001'; + type Tab = 'rooms' | 'journal' | 'habits' | 'resources'; export default function Dashboard() { @@ -117,6 +120,9 @@ function TabButton({ active, onClick, children }: { active: boolean; onClick: () ); } +// ---------------------------------------------------------------------- +// ROOMS TAB COMPONENT (Auto-Updates) +// ---------------------------------------------------------------------- function RoomsTab() { const [rooms, setRooms] = useState([]); const [loading, setLoading] = useState(true); @@ -124,18 +130,61 @@ function RoomsTab() { const [currentUser, setCurrentUser] = useState(null); useEffect(() => { - loadRooms(); + // 1. Initial Load + loadRoomsAndCounts(); loadCurrentUser(); + + // 2. Poll for updates every 3 seconds + const interval = setInterval(() => { + loadRoomsAndCounts(false); // false = don't show loading spinner on updates + }, 3000); + + // Cleanup interval on unmount + return () => clearInterval(interval); }, []); - async function loadRooms() { + // Modified to optionally skip the loading spinner + async function loadRoomsAndCounts(showLoading = true) { try { - const data = await roomsApi.getAll(); - setRooms(data); + if (showLoading) setLoading(true); + + const roomData = await roomsApi.getAll(); + const session = await getSession(); + const token = session?.access_token; + + if (!token) { + setRooms(roomData); + if (showLoading) setLoading(false); + return; + } + + const roomsWithCounts = await Promise.all( + roomData.map(async (room: any) => { + try { + const res = await fetch(`${API_URL}/api/rooms/${room.id}/activity`, { + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (res.ok) { + const countData = await res.json(); + return { ...room, active_count: countData.count }; + } else { + return { ...room, active_count: 0 }; + } + } catch (err) { + return { ...room, active_count: 0 }; + } + }) + ); + + setRooms(roomsWithCounts); } catch (error) { console.error('Error loading rooms:', error); } finally { - setLoading(false); + if (showLoading) setLoading(false); } } @@ -174,7 +223,17 @@ function RoomsTab() {

{room.name}

-

{room.member_count || 0} members online

+ +
+ + 0 ? 'bg-green-400' : 'bg-gray-400'}`}> + 0 ? 'bg-green-500' : 'bg-gray-400'}`}> + +

+ {room.active_count || 0} members online +

+
+

{room.description}

@@ -188,12 +247,15 @@ function RoomsTab() { ))} - {/* Chat Room Modal */} {selectedRoom && currentUser && ( setSelectedRoom(null)} + onClose={() => { + setSelectedRoom(null); + // Immediate refresh when closing + loadRoomsAndCounts(false); + }} /> )} @@ -318,4 +380,4 @@ function ResourcesTab() { )} ); -} +} \ No newline at end of file