diff --git a/frontend/src/hooks/useEventChat.js b/frontend/src/hooks/useEventChat.js new file mode 100644 index 0000000..3002ca7 --- /dev/null +++ b/frontend/src/hooks/useEventChat.js @@ -0,0 +1,180 @@ +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); + + // 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; + } + + // Socket event listeners + socket.on('connect', () => { + setIsConnected(true); + // Join event room + socket.emit('join_event_room', { slug }); + }); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + // Receive message history (initial 20 messages) + socket.on('message_history', (history) => { + setMessages(history); + setHasMore(history.length === 20); + }); + + // Receive new messages + socket.on('event_message', (message) => { + setMessages((prev) => [...prev, message]); + }); + + // Receive active users list + socket.on('active_users', (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); + 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'); + socket.off('disconnect'); + 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) { + // Save current scroll position + const container = messagesContainerRef.current; + const oldScrollHeight = container?.scrollHeight || 0; + const oldScrollTop = container?.scrollTop || 0; + + // Prepend older messages + setMessages((prev) => [...response.data, ...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, + // Actions + sendMessage, + loadOlderMessages + }; +}; + +export default useEventChat; diff --git a/frontend/src/hooks/useForm.js b/frontend/src/hooks/useForm.js new file mode 100644 index 0000000..9d1ffdd --- /dev/null +++ b/frontend/src/hooks/useForm.js @@ -0,0 +1,89 @@ +import { useState } from 'react'; + +/** + * Custom hook for form state management + * + * Eliminates repetitive useState and onChange handlers for each form field + * + * @param {Object} initialValues - Initial form values { fieldName: value, ... } + * @param {Function} onSubmit - Optional submit handler + * @returns {Object} Form state and handlers + * + * @example + * const { values, handleChange, handleSubmit, reset } = useForm( + * { email: '', password: '' }, + * async (values) => { await login(values.email, values.password); } + * ); + * + * + *
+ */ +const useForm = (initialValues = {}, onSubmit) => { + const [values, setValues] = useState(initialValues); + + /** + * Generic change handler for all form fields + * Works with any input that has name and value attributes + */ + const handleChange = (e) => { + const { name, value, type, checked } = e.target; + + // Handle checkboxes differently + const fieldValue = type === 'checkbox' ? checked : value; + + setValues(prev => ({ + ...prev, + [name]: fieldValue + })); + }; + + /** + * Handle form submission + * Prevents default, calls optional onSubmit callback + */ + const handleSubmit = async (e) => { + e.preventDefault(); + if (onSubmit) { + await onSubmit(values); + } + }; + + /** + * Reset form to initial values + */ + const reset = () => { + setValues(initialValues); + }; + + /** + * Set multiple values at once + * Useful for pre-filling form from API data + */ + const setFormValues = (newValues) => { + setValues(prev => ({ + ...prev, + ...newValues + })); + }; + + /** + * Set a single field value programmatically + */ + const setValue = (name, value) => { + setValues(prev => ({ + ...prev, + [name]: value + })); + }; + + return { + values, + handleChange, + handleSubmit, + reset, + setFormValues, + setValue + }; +}; + +export default useForm; diff --git a/frontend/src/hooks/useMatchChat.js b/frontend/src/hooks/useMatchChat.js new file mode 100644 index 0000000..f40567e --- /dev/null +++ b/frontend/src/hooks/useMatchChat.js @@ -0,0 +1,122 @@ +import { useState, useEffect } from 'react'; +import { connectSocket, getSocket } from '../services/socket'; +import { matchesAPI } from '../services/api'; + +/** + * Custom hook for Match Chat functionality (1:1 private chat) + * Extracts Socket.IO logic and chat state management from MatchChatPage + * + * @param {object} match - Match object with id and partner info + * @param {number} userId - Current user ID + * @param {string} slug - Match slug for API calls + * @returns {object} Chat state and handlers + * + * @example + * const { + * messages, + * isConnected, + * sendMessage, + * newMessage, + * setNewMessage + * } = useMatchChat(match, user.id, slug); + */ +const useMatchChat = (match, userId, slug) => { + // Chat state + const [messages, setMessages] = useState([]); + const [newMessage, setNewMessage] = useState(''); + const [isConnected, setIsConnected] = useState(false); + + // Load message history from API + useEffect(() => { + const loadMessages = async () => { + if (!match || !slug) return; + + try { + const result = await matchesAPI.getMatchMessages(slug); + setMessages(result.data || []); + } catch (error) { + console.error('Failed to load messages:', error); + } + }; + loadMessages(); + }, [match, slug]); + + // Socket.IO connection and event listeners + useEffect(() => { + // Wait for match to be loaded + if (!match) return; + + // Connect to Socket.IO + const socket = connectSocket(); + + if (!socket) { + console.error('Failed to connect to socket'); + return; + } + + // Helper to join match room + const joinMatchRoom = () => { + setIsConnected(true); + socket.emit('join_match_room', { matchId: match.id }); + console.log(`Joined match room ${match.id}`); + }; + + // Socket event listeners + socket.on('connect', joinMatchRoom); + + socket.on('disconnect', () => { + setIsConnected(false); + }); + + // Receive messages + socket.on('match_message', (message) => { + setMessages((prev) => [...prev, message]); + }); + + // Join immediately if already connected + if (socket.connected) { + joinMatchRoom(); + } + + // Cleanup + return () => { + socket.off('connect', joinMatchRoom); + socket.off('disconnect'); + socket.off('match_message'); + }; + }, [match, userId]); + + /** + * Send a message to the match chat + */ + const sendMessage = (e) => { + e.preventDefault(); + if (!newMessage.trim() || !match) return; + + const socket = getSocket(); + if (!socket || !socket.connected) { + alert('Not connected to chat server'); + return; + } + + // Send message via Socket.IO using numeric match ID + socket.emit('send_match_message', { + matchId: match.id, + content: newMessage, + }); + + setNewMessage(''); + }; + + return { + // State + messages, + newMessage, + setNewMessage, + isConnected, + // Actions + sendMessage + }; +}; + +export default useMatchChat; diff --git a/frontend/src/pages/EventChatPage.jsx b/frontend/src/pages/EventChatPage.jsx index 6bad930..a79b937 100644 --- a/frontend/src/pages/EventChatPage.jsx +++ b/frontend/src/pages/EventChatPage.jsx @@ -11,6 +11,7 @@ import ChatMessageList from '../components/chat/ChatMessageList'; import ChatInput from '../components/chat/ChatInput'; import ConfirmationModal from '../components/modals/ConfirmationModal'; import Modal from '../components/modals/Modal'; +import useEventChat from '../hooks/useEventChat'; const EventChatPage = () => { const { slug } = useParams(); @@ -19,18 +20,25 @@ const EventChatPage = () => { const [event, setEvent] = useState(null); const [isParticipant, setIsParticipant] = useState(false); const [loading, setLoading] = useState(true); - const [messages, setMessages] = useState([]); - const [newMessage, setNewMessage] = useState(''); - const [activeUsers, setActiveUsers] = useState([]); const [checkedInUsers, setCheckedInUsers] = useState([]); - const [isConnected, setIsConnected] = useState(false); - const [loadingOlder, setLoadingOlder] = useState(false); - const [hasMore, setHasMore] = useState(true); const [showLeaveModal, setShowLeaveModal] = useState(false); const [isLeaving, setIsLeaving] = useState(false); const messagesEndRef = useRef(null); const messagesContainerRef = useRef(null); + // Event Chat hook - manages messages, Socket.IO, and active users + const { + messages, + newMessage, + setNewMessage, + activeUsers, + isConnected, + loadingOlder, + hasMore, + sendMessage: handleSendMessage, + loadOlderMessages + } = useEventChat(slug, user?.id, event, messagesContainerRef); + // Heats state const [myHeats, setMyHeats] = useState([]); const [userHeats, setUserHeats] = useState(new Map()); @@ -116,62 +124,15 @@ const EventChatPage = () => { scrollToBottom(); }, [messages]); + // Heats updates listener (specific to EventChatPage) useEffect(() => { if (!event) return; - // Connect to Socket.IO - const socket = connectSocket(); - - if (!socket) { - console.error('Failed to connect to socket'); - return; - } - - // Socket event listeners - socket.on('connect', () => { - setIsConnected(true); - // Join event room - socket.emit('join_event_room', { slug }); - }); - - socket.on('disconnect', () => { - setIsConnected(false); - }); - - // Receive message history (initial 20 messages) - socket.on('message_history', (history) => { - setMessages(history); - setHasMore(history.length === 20); - }); - - // Receive new messages - socket.on('event_message', (message) => { - setMessages((prev) => [...prev, message]); - }); - - // Receive active users list - socket.on('active_users', (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 !== user.id); - 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`); - }); + const socket = getSocket(); + if (!socket) return; // Heats updated notification - socket.on('heats_updated', (data) => { + const handleHeatsUpdated = (data) => { const { userId, heats } = data; // Update userHeats map @@ -190,72 +151,14 @@ const EventChatPage = () => { setMyHeats(heats || []); setShowHeatsBanner(heats.length === 0); } - }); - - // Cleanup - return () => { - socket.emit('leave_event_room'); - socket.off('connect'); - socket.off('disconnect'); - socket.off('message_history'); - socket.off('event_message'); - socket.off('active_users'); - socket.off('user_joined'); - socket.off('user_left'); - socket.off('heats_updated'); }; - }, [event, slug, user.id]); - const handleSendMessage = (e) => { - e.preventDefault(); - if (!newMessage.trim()) return; + socket.on('heats_updated', handleHeatsUpdated); - 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(''); - }; - - 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) { - // Save current scroll position - const container = messagesContainerRef.current; - const oldScrollHeight = container.scrollHeight; - const oldScrollTop = container.scrollTop; - - // Prepend older messages - setMessages((prev) => [...response.data, ...prev]); - setHasMore(response.hasMore); - - // Restore scroll position (adjust for new content) - setTimeout(() => { - 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 () => { + socket.off('heats_updated', handleHeatsUpdated); + }; + }, [event, user.id]); const handleMatchWith = async (userId) => { try { diff --git a/frontend/src/pages/MatchChatPage.jsx b/frontend/src/pages/MatchChatPage.jsx index 63b9983..11a76b2 100644 --- a/frontend/src/pages/MatchChatPage.jsx +++ b/frontend/src/pages/MatchChatPage.jsx @@ -11,6 +11,7 @@ import WebRTCWarning from '../components/WebRTCWarning'; import Avatar from '../components/common/Avatar'; import ChatMessageList from '../components/chat/ChatMessageList'; import ChatInput from '../components/chat/ChatInput'; +import useMatchChat from '../hooks/useMatchChat'; const MatchChatPage = () => { const { slug } = useParams(); @@ -18,16 +19,22 @@ const MatchChatPage = () => { const navigate = useNavigate(); const [match, setMatch] = useState(null); const [loading, setLoading] = useState(true); - const [messages, setMessages] = useState([]); - const [newMessage, setNewMessage] = useState(''); const [selectedFile, setSelectedFile] = useState(null); const [showLinkInput, setShowLinkInput] = useState(false); const [videoLink, setVideoLink] = useState(''); - const [isConnected, setIsConnected] = useState(false); const [webrtcDetection, setWebrtcDetection] = useState(null); const messagesEndRef = useRef(null); const fileInputRef = useRef(null); + // Match Chat hook - manages messages and Socket.IO + const { + messages, + newMessage, + setNewMessage, + isConnected, + sendMessage: handleSendMessage + } = useMatchChat(match, user?.id, slug); + // WebRTC hook const { connectionState, @@ -72,21 +79,6 @@ const MatchChatPage = () => { loadMatch(); }, [slug, navigate]); - // Load message history - useEffect(() => { - const loadMessages = async () => { - if (!match) return; - - try { - const result = await matchesAPI.getMatchMessages(slug); - setMessages(result.data || []); - } catch (error) { - console.error('Failed to load messages:', error); - } - }; - loadMessages(); - }, [match, slug]); - const partner = match?.partner; const scrollToBottom = () => { @@ -97,69 +89,6 @@ const MatchChatPage = () => { scrollToBottom(); }, [messages]); - useEffect(() => { - // Wait for match to be loaded - if (!match) return; - - // Connect to Socket.IO - const socket = connectSocket(); - - if (!socket) { - console.error('Failed to connect to socket'); - return; - } - - // Helper to join match room - const joinMatchRoom = () => { - setIsConnected(true); - socket.emit('join_match_room', { matchId: match.id }); - console.log(`Joined match room ${match.id}`); - }; - - // Socket event listeners - socket.on('connect', joinMatchRoom); - - socket.on('disconnect', () => { - setIsConnected(false); - }); - - // Receive messages - socket.on('match_message', (message) => { - setMessages((prev) => [...prev, message]); - }); - - // Join immediately if already connected - if (socket.connected) { - joinMatchRoom(); - } - - // Cleanup - return () => { - socket.off('connect', joinMatchRoom); - socket.off('disconnect'); - socket.off('match_message'); - }; - }, [match, user.id]); - - const handleSendMessage = (e) => { - e.preventDefault(); - if (!newMessage.trim() || !match) return; - - const socket = getSocket(); - if (!socket || !socket.connected) { - alert('Not connected to chat server'); - return; - } - - // Send message via Socket.IO using numeric match ID - socket.emit('send_match_message', { - matchId: match.id, - content: newMessage, - }); - - setNewMessage(''); - }; - const handleFileSelect = (e) => { const file = e.target.files[0]; if (file && file.type.startsWith('video/')) {