refactor(frontend): Phase 2 - extract business logic into custom hooks

Separate concerns - move Socket.IO and form logic from components to reusable hooks

New Hooks:
- useForm: Generic form state management with handleChange/handleSubmit/reset
- useEventChat: Extract Socket.IO logic from EventChatPage (156 lines)
  * Manages messages, active users, connection state
  * Handles send message, load older messages with scroll preservation
  * Real-time updates via Socket.IO event listeners
- useMatchChat: Extract Socket.IO logic from MatchChatPage (115 lines)
  * Manages 1:1 chat messages and connection
  * Loads message history from API
  * Real-time message sync via Socket.IO

Pages Refactored:
- EventChatPage: 661 → 564 lines (-97 lines, -15%)
- MatchChatPage: 517 → 446 lines (-71 lines, -14%)

Benefits:
- Cleaner component code - UI separated from business logic
- Reusable hooks can be used in other components
- Easier to test - hooks can be unit tested independently
- Better code organization - single responsibility principle
- 168 lines eliminated from pages, moved to 271 lines of reusable hooks

Phase 2 Total: -168 lines
Grand Total (Phase 1+2): -389 lines (-12%)
This commit is contained in:
Radosław Gierwiało
2025-11-21 17:02:04 +01:00
parent dea9d70bb9
commit 9e74343c3b
5 changed files with 424 additions and 201 deletions

View File

@@ -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;

View File

@@ -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); }
* );
*
* <input name="email" value={values.email} onChange={handleChange} />
* <form onSubmit={handleSubmit}>...</form>
*/
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;

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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/')) {