Files
spotlightcam/frontend/src/hooks/useEventChat.js
Radosław Gierwiało dd3176196e fix(chat): real-time active users list updates
Fixed issue where active users list in event chat did not update in
real-time when new users joined. Users had to refresh the page to see
newly joined participants.

Root Cause:
- getAllDisplayUsers() used checkedInUsers (loaded once from API) as
  base list, with activeUsers (Socket.IO real-time) only for isOnline flag
- When new user joined chat, they appeared in activeUsers but not in
  checkedInUsers, so they were not displayed

Solution:
- Rewrote getAllDisplayUsers() to prioritize activeUsers (real-time data)
- Merges activeUsers (online) with checkedInUsers (offline checked-in users)
- Uses Socket.IO data as source of truth for online users
- Enriches with database data when available (firstName, lastName, etc)
- Sorts online users first, offline second

Changes:
- EventChatPage.jsx: Rewrote getAllDisplayUsers() to merge activeUsers
  with checkedInUsers, prioritizing real-time Socket.IO data
- useEventChat.js: Added debug logging for active_users events
- socket/index.js: Added debug logging for active_users emissions

Testing:
- User A in chat sees User B appear immediately when B joins
- No page refresh required
- Online/offline status updates in real-time
2025-12-02 23:38:46 +01:00

278 lines
7.8 KiB
JavaScript

import { useState, useEffect } from 'react';
import { connectSocket, getSocket } from '../services/socket';
import { eventsAPI } from '../services/api';
/**
* Custom hook for Event Chat functionality
* Extracts Socket.IO logic and chat state management from EventChatPage
*
* @param {string} slug - Event slug
* @param {number} userId - Current user ID
* @param {object} event - Event object (needed to check if event exists)
* @param {object} messagesContainerRef - Ref to messages container for scroll management
* @returns {object} Chat state and handlers
*
* @example
* const {
* messages,
* isConnected,
* activeUsers,
* sendMessage,
* newMessage,
* setNewMessage,
* loadOlderMessages,
* loadingOlder,
* hasMore
* } = useEventChat(slug, user.id, event, messagesContainerRef);
*/
const useEventChat = (slug, userId, event, messagesContainerRef) => {
// Chat state
const [messages, setMessages] = useState([]);
const [newMessage, setNewMessage] = useState('');
const [activeUsers, setActiveUsers] = useState([]);
const [isConnected, setIsConnected] = useState(false);
const [loadingOlder, setLoadingOlder] = useState(false);
const [hasMore, setHasMore] = useState(true);
// User and participant caches (normalized data)
const [userCache, setUserCache] = useState({});
const [participantCache, setParticipantCache] = useState({});
// Helper to update caches from message data
const updateCaches = (messageData) => {
if (messageData.user) {
setUserCache(prev => ({
...prev,
[messageData.userId]: messageData.user
}));
}
if (messageData.participant) {
setParticipantCache(prev => ({
...prev,
[messageData.userId]: messageData.participant
}));
}
};
// Socket.IO connection and event listeners
useEffect(() => {
if (!event) return;
// Connect to Socket.IO
const socket = connectSocket();
if (!socket) {
console.error('Failed to connect to socket');
return;
}
// Function to join room (used on connect and reconnect)
const joinRoom = () => {
setIsConnected(true);
socket.emit('join_event_room', { slug });
};
// Check if already connected (socket instance may already be connected)
if (socket.connected) {
joinRoom();
}
// Socket event listeners
socket.on('connect', joinRoom);
socket.on('disconnect', (reason) => {
setIsConnected(false);
console.log('🔌 Disconnected:', reason);
});
// Handle reconnection - rejoin room automatically
socket.on('reconnect', (attemptNumber) => {
console.log('🔄 Reconnected after', attemptNumber, 'attempts');
// Room will be joined via 'connect' event
});
socket.on('reconnect_attempt', (attemptNumber) => {
console.log('🔄 Reconnection attempt', attemptNumber);
});
socket.on('reconnect_error', (error) => {
console.error('🔄 Reconnection error:', error);
});
// Receive message history (initial 20 messages)
socket.on('message_history', (history) => {
// Update caches from all messages
history.forEach(msg => updateCaches(msg));
// Store only core message data
const coreMessages = history.map(msg => ({
id: msg.id,
userId: msg.userId,
content: msg.content,
createdAt: msg.createdAt,
roomId: msg.roomId,
type: msg.type,
}));
setMessages(coreMessages);
setHasMore(history.length === 20);
});
// Receive new messages
socket.on('event_message', (message) => {
// Update caches with user/participant data
updateCaches(message);
setMessages((prev) => {
// Prevent duplicates - check if message ID already exists
if (prev.some(m => m.id === message.id)) {
return prev;
}
// Store only core message data
return [...prev, {
id: message.id,
userId: message.userId,
content: message.content,
createdAt: message.createdAt,
roomId: message.roomId,
type: message.type,
}];
});
});
// Receive active users list
socket.on('active_users', (users) => {
console.log('📡 Received active_users event:', users);
// Filter out duplicates and current user
const uniqueUsers = users
.filter((u, index, self) =>
index === self.findIndex((t) => t.userId === u.userId)
)
.filter((u) => u.userId !== userId);
console.log('👥 Updating active users:', uniqueUsers);
setActiveUsers(uniqueUsers);
});
// User joined notification
socket.on('user_joined', (userData) => {
console.log(`${userData.username} joined the room`);
});
// User left notification
socket.on('user_left', (userData) => {
console.log(`${userData.username} left the room`);
});
// Cleanup
return () => {
socket.emit('leave_event_room');
socket.off('connect', joinRoom);
socket.off('disconnect');
socket.off('reconnect');
socket.off('reconnect_attempt');
socket.off('reconnect_error');
socket.off('message_history');
socket.off('event_message');
socket.off('active_users');
socket.off('user_joined');
socket.off('user_left');
};
}, [event, slug, userId]);
/**
* Send a message to the event chat
*/
const sendMessage = (e) => {
e.preventDefault();
if (!newMessage.trim()) return;
const socket = getSocket();
if (!socket || !socket.connected) {
alert('Not connected to chat server');
return;
}
// Send message via Socket.IO
socket.emit('send_event_message', {
content: newMessage,
});
setNewMessage('');
};
/**
* Load older messages (pagination)
* Preserves scroll position when loading older messages
*/
const loadOlderMessages = async () => {
if (loadingOlder || !hasMore || messages.length === 0) return;
setLoadingOlder(true);
try {
const oldestMessageId = messages[0].id;
const response = await eventsAPI.getMessages(slug, oldestMessageId, 20);
if (response.data.length > 0) {
// Update caches from all loaded messages
response.data.forEach(msg => updateCaches(msg));
// Save current scroll position
const container = messagesContainerRef.current;
const oldScrollHeight = container?.scrollHeight || 0;
const oldScrollTop = container?.scrollTop || 0;
// Prepend older messages (filter out any duplicates) - store only core data
setMessages((prev) => {
const existingIds = new Set(prev.map(m => m.id));
const newMessages = response.data
.filter(m => !existingIds.has(m.id))
.map(msg => ({
id: msg.id,
userId: msg.userId,
content: msg.content,
createdAt: msg.createdAt,
roomId: msg.roomId,
type: msg.type,
}));
return [...newMessages, ...prev];
});
setHasMore(response.hasMore);
// Restore scroll position (adjust for new content)
setTimeout(() => {
if (container) {
const newScrollHeight = container.scrollHeight;
container.scrollTop = oldScrollTop + (newScrollHeight - oldScrollHeight);
}
}, 0);
} else {
setHasMore(false);
}
} catch (error) {
console.error('Failed to load older messages:', error);
} finally {
setLoadingOlder(false);
}
};
return {
// State
messages,
newMessage,
setNewMessage,
activeUsers,
isConnected,
loadingOlder,
hasMore,
// Caches for normalized data
userCache,
participantCache,
// Actions
sendMessage,
loadOlderMessages
};
};
export default useEventChat;