From: charleswrayjr Date: Sat, 27 Sep 2025 05:20:12 +0000 (-0500) Subject: Adding proper file upload management to chat #PHO-2. X-Git-Url: https://git.phasecustomsoft.com/static/gitweb.js?a=commitdiff_plain;h=15656ac816d3e726d7c53f976cdb682249e8cd9f;p=phs-home.git Adding proper file upload management to chat #PHO-2. --- diff --git a/src/app/components/Chat.jsx b/src/app/components/Chat.jsx deleted file mode 100755 index 1919425..0000000 --- a/src/app/components/Chat.jsx +++ /dev/null @@ -1,347 +0,0 @@ -/** - * @file Chat component for real-time messaging - * @module Chat - */ - -import React, { useEffect, useRef, useState } from 'react'; -import axios from 'axios'; -import { API_BASE_URL } from '../../App'; -import AddIcon from '@mui/icons-material/Add'; -import { - Button, - Card, - CardActions, - CardContent, - CardHeader, - Grid, - IconButton, - InputAdornment, - TextField -} from '@mui/material'; -import ChatBubble from './ChatBubble'; - -/** - * @typedef {Object} User - * @property {number} id - * @property {string} first_name - * @property {string} middle_name - * @property {string} last_name - */ - -/** - * Chat component - * @param {Object} props - Component props - * @param {Object|null} props.user - Current user - * @param {Object} props.socket - Socket.io client instance - * @returns {JSX.Element} Rendered component - */ -const Chat = ( { user, socket } ) => { - const [conversations, setConversations] = useState( [] ); - const [selectedConversation, setSelectedConversation] = useState( null ); - const [messages, setMessages] = useState( [] ); - const [content, setContent] = useState( '' ); - const [attachedFiles, setAttachedFiles] = useState( [] ); - const [users, setUsers] = useState( [] ); - const [groups, setGroups] = useState( [] ); - const [error, setError] = useState( null ); - const [isSidebarOpen, setIsSidebarOpen] = useState( true ); - const messagesEndRef = useRef( null ); - const fileRef = useRef( null ); - - useEffect( () => { - if (user) { - const fetchData = async () => { - try { - const [usersRes, groupsRes, messagesRes] = await Promise.all( [ - axios.get( `${ API_BASE_URL }/user`, { withCredentials:true } ), - axios.get( `${ API_BASE_URL }/message_group`, { withCredentials:true } ), - axios.get( `${ API_BASE_URL }/message`, { withCredentials:true } ) - ] ); - setUsers( Array.isArray( usersRes.data ) ? usersRes.data.filter( u => u.id !== user.id ) : [] ); - setGroups( Array.isArray( groupsRes.data ) ? groupsRes.data : [] ); - - // Group messages by recipient_id or group_id - const messages = Array.isArray( messagesRes.data ) ? messagesRes.data : []; - const convoMap = {}; - messages.forEach( msg => { - if (msg.group_id) { - // Group chat - const key = `group_${ msg.group_id }`; - if (!convoMap[key]) { - convoMap[key] = { - id:key, - type:'group', - targetId:msg.group_id, - name:groupsRes.data.find( g => g.id === msg.group_id )?.name || 'Group', - lastMessage:msg.content, - lastMessageTime:msg.created_at - }; - } - } else { - // Private chat: Use sorted user IDs to ensure single conversation - const otherUserId = msg.sender_id === user.id ? msg.recipient_id : msg.sender_id; - const key = `user_${ [user.id, otherUserId].sort( ( a, b ) => a - b ).join( '_' ) }`; - if (!convoMap[key]) { - convoMap[key] = { - id:key, - type:'user', - targetId:otherUserId, - name:usersRes.data.find( u => u.id === otherUserId )?.first_name || 'User', - lastMessage:msg.content, - lastMessageTime:msg.created_at - }; - } - } - } ); - setConversations( Object.values( convoMap ).sort( ( a, b ) => new Date( b.lastMessageTime ) - new Date( a.lastMessageTime ) ) ); - setMessages( messages ); - } catch (err) { - console.error( 'Chat data fetch error:', err ); - setError( 'Failed to load chat data' ); - } - }; - fetchData().catch(); - - socket.on( 'newMessage', msg => { - setMessages( prev => (Array.isArray( prev ) ? [...prev, msg] : [msg]) ); - setConversations( prev => { - let key, convo; - if (msg.group_id) { - key = `group_${ msg.group_id }`; - convo = { - id:key, - type:'group', - targetId:msg.group_id, - name:groups.find( g => g.id === msg.group_id )?.name || 'Group', - lastMessage:msg.content, - lastMessageTime:msg.created_at - }; - } else { - const otherUserId = msg.sender_id === user.id ? msg.recipient_id : msg.sender_id; - key = `user_${ [user.id, otherUserId].sort( ( a, b ) => a - b ).join( '_' ) }`; - convo = { - id:key, - type:'user', - targetId:otherUserId, - name:users.find( u => u.id === otherUserId )?.first_name || 'User', - lastMessage:msg.content, - lastMessageTime:msg.created_at - }; - } - return [convo, ...prev.filter( c => c.id !== key )].sort( ( a, b ) => new Date( b.lastMessageTime ) - new Date( a.lastMessageTime ) ); - } ); - } ); - - socket.on( 'newReaction', ( { messageId, reaction_data } ) => { - setMessages( prev => { - if (!Array.isArray( prev )) return prev; - return prev.map( m => m.id === parseInt( messageId ) ? { - ...m, - reactions:[...(Array.isArray( m.reactions ) ? Array.from( new Set( m.reactions ) ) : []), reaction_data] - } : m ); - } ); - } ); - - return () => { - socket.off( 'newMessage' ); - socket.off( 'newReaction' ); - }; - } - }, [user] ); - - useEffect( () => { - messagesEndRef.current?.scrollIntoView( { behavior:'smooth' } ); - }, [messages] ); - - /** - * Select a conversation to view messages - * @param {Object} convo - Conversation object - */ - const selectConversation = ( convo ) => { - setSelectedConversation( convo ); - setMessages( - Array.isArray( messages ) - ? messages.filter( m => - (convo.type === 'group' && m.group_id === convo.targetId) || - (convo.type === 'user' && (m.recipient_id === convo.targetId || m.sender_id === convo.targetId)) - ) - : [] - ); - setIsSidebarOpen( false ); - }; - - /** - * Send a message with optional file attachments - */ - const sendMessage = async () => { - if (content.trim() || attachedFiles.length) { - let fileIds = []; - try { - if (attachedFiles.length) { - const formData = new FormData(); - attachedFiles.forEach( f => formData.append( 'files', f ) ); - formData.append( 'visibility', selectedConversation?.type === 'group' ? 'family' : selectedConversation?.type === 'user' ? 'specific' : 'family' ); - if (selectedConversation?.type === 'user') formData.append( 'specific_users', JSON.stringify( [selectedConversation.targetId] ) ); - const res = await axios.post( `${ API_BASE_URL }/file/create`, formData, { - headers:{ 'Content-Type':'multipart/form-data' }, - withCredentials:true - } ); - fileIds = Array.isArray( res.data ) ? res.data.map( f => f.id ) : [res.data.id]; - } - socket.emit( 'chatMessage', { - content, - recipientId:selectedConversation?.type === 'user' ? selectedConversation.targetId : null, - groupId:selectedConversation?.type === 'group' ? selectedConversation.targetId : null, - fileIds - } ); - setContent( '' ); - setAttachedFiles( [] ); - } catch (err) { - console.error( 'Send message error:', err ); - setError( 'Failed to send message' ); - } - } - }; - - if (!user) { - return
Please log in to access chat
; - } - - if (error) { - return
{ error }
; - } - - /** - * - * @returns {React.ReactNode[]} - */ - const getConvos = () => ( - conversations.map( convo => ( -
selectConversation( convo ) } - className={ `p-2 mb-2 rounded cursor-pointer ${ selectedConversation?.id === convo.id ? 'bg-blue-200' : 'hover:bg-gray-200' }` }> -

{ convo.name } ({ convo.type === 'group' ? 'Group' : 'Private' })

-

{ convo.lastMessage }

-

{ new Date( convo.lastMessageTime ).toLocaleString() }

-
- ) ) - ); - - /** - * - * @returns {React.ReactNode[]} - */ - const getMessages = () => ( - messages.map( ( msg, i ) => ( - -
- -
-
- ) ) - ); - - return ( - - { isSidebarOpen && - { getConvos() } - } - - - -

- { selectedConversation ? `${ selectedConversation.name } (${ selectedConversation.type === 'group' ? 'Group' : 'Private' })` : 'Select a conversation' } -

- - }/> - -
- { selectedConversation ? ( - messages.length === 0 ? ( -

No messages in this conversation

- ) : ( - - { getMessages() } - - ) - ) : ( -

Select a conversation to start chatting

- ) } -
-
- - - setAttachedFiles( Array.from( e.target.files ) ) } - className="mb-2"/> - { selectedConversation ? ( - setContent( e.target.value ) } - placeholder="Type a message" - fullWidth - slotProps={ { - input:{ - startAdornment: - - - , - endAdornment: - - - - } - } }/> - /*
- setAttachedFiles( Array.from( e.target.files ) ) } - className="mb-2"/> -
- setContent( e.target.value ) } - placeholder="Type a message" - className="border p-2 flex-grow rounded-l"/> - -
-
*/ - ) : null } -
- - - - ); -}; - -export default Chat; \ No newline at end of file diff --git a/src/app/views/Chat/Chat.jsx b/src/app/views/Chat/Chat.jsx new file mode 100755 index 0000000..43a3cf3 --- /dev/null +++ b/src/app/views/Chat/Chat.jsx @@ -0,0 +1,447 @@ +/** + * @file Chat component for real-time messaging + * @module Chat + */ + +import React, { useEffect, useRef, useState } from 'react'; +import axios from 'axios'; +import { API_BASE_URL } from '../../../App'; +import AddIcon from '@mui/icons-material/Add'; +import { + Button, + Card, + CardActions, + CardContent, + CardHeader, + Grid, + IconButton, + InputAdornment, + TextField, + Box, + CardMedia, + Chip, + LinearProgress +} from '@mui/material'; +import ChatBubble from '../../components/ChatBubble'; + +/** + * @typedef {Object} User + * @property {number} id + * @property {string} first_name + * @property {string} middle_name + * @property {string} last_name + */ + +/** + * Chat component + * @param {Object} props - Component props + * @param {Object|null} props.user - Current user + * @param {Object} props.socket - Socket.io client instance + * @returns {JSX.Element} Rendered component + */ +const Chat = ( { user, socket, maxSizeMB = 10 } ) => { + const [conversations, setConversations] = useState( [] ); + const [selectedConversation, setSelectedConversation] = useState( null ); + const [messages, setMessages] = useState( [] ); + const [content, setContent] = useState( '' ); + const [attachedFiles, setAttachedFiles] = useState( [] ); + const [users, setUsers] = useState( [] ); + const [groups, setGroups] = useState( [] ); + const [error, setError] = useState( null ); + const [isSidebarOpen, setIsSidebarOpen] = useState( true ); + const messagesEndRef = useRef( null ); + const fileRef = useRef( null ); + + /** + * @type {[MediaFile[], React.Dispatch>]} + */ + const [selectedMedia, setSelectedMedia] = useState([]); + /** + * @type {[string[], React.Dispatch>]} + */ + const [previews, setPreviews] = useState([]); + /** + * @type {[number, React.Dispatch>]} + */ + const [progress, setProgress] = useState(0); + + useEffect( () => { + if (user) { + const fetchData = async () => { + try { + const [usersRes, groupsRes, messagesRes] = await Promise.all( [ + axios.get( `${ API_BASE_URL }/user`, { withCredentials:true } ), + axios.get( `${ API_BASE_URL }/message_group`, { withCredentials:true } ), + axios.get( `${ API_BASE_URL }/message`, { withCredentials:true } ) + ] ); + setUsers( Array.isArray( usersRes.data ) ? usersRes.data.filter( u => u.id !== user.id ) : [] ); + setGroups( Array.isArray( groupsRes.data ) ? groupsRes.data : [] ); + + // Group messages by recipient_id or group_id + const messages = Array.isArray( messagesRes.data ) ? messagesRes.data : []; + const convoMap = {}; + messages.forEach( msg => { + if (msg.group_id) { + // Group chat + const key = `group_${ msg.group_id }`; + if (!convoMap[key]) { + convoMap[key] = { + id:key, + type:'group', + targetId:msg.group_id, + name:groupsRes.data.find( g => g.id === msg.group_id )?.name || 'Group', + lastMessage:msg.content, + lastMessageTime:msg.created_at + }; + } + } else { + // Private chat: Use sorted user IDs to ensure single conversation + const otherUserId = msg.sender_id === user.id ? msg.recipient_id : msg.sender_id; + const key = `user_${ [user.id, otherUserId].sort( ( a, b ) => a - b ).join( '_' ) }`; + if (!convoMap[key]) { + convoMap[key] = { + id:key, + type:'user', + targetId:otherUserId, + name:usersRes.data.find( u => u.id === otherUserId )?.first_name || 'User', + lastMessage:msg.content, + lastMessageTime:msg.created_at + }; + } + } + } ); + setConversations( Object.values( convoMap ).sort( ( a, b ) => new Date( b.lastMessageTime ) - new Date( a.lastMessageTime ) ) ); + setMessages( messages ); + } catch (err) { + console.error( 'Chat data fetch error:', err ); + setError( 'Failed to load chat data' ); + } + }; + fetchData().catch(); + + socket.on( 'newMessage', msg => { + setMessages( prev => (Array.isArray( prev ) ? [...prev, msg] : [msg]) ); + setConversations( prev => { + let key, convo; + if (msg.group_id) { + key = `group_${ msg.group_id }`; + convo = { + id:key, + type:'group', + targetId:msg.group_id, + name:groups.find( g => g.id === msg.group_id )?.name || 'Group', + lastMessage:msg.content, + lastMessageTime:msg.created_at + }; + } else { + const otherUserId = msg.sender_id === user.id ? msg.recipient_id : msg.sender_id; + key = `user_${ [user.id, otherUserId].sort( ( a, b ) => a - b ).join( '_' ) }`; + convo = { + id:key, + type:'user', + targetId:otherUserId, + name:users.find( u => u.id === otherUserId )?.first_name || 'User', + lastMessage:msg.content, + lastMessageTime:msg.created_at + }; + } + return [convo, ...prev.filter( c => c.id !== key )].sort( ( a, b ) => new Date( b.lastMessageTime ) - new Date( a.lastMessageTime ) ); + } ); + } ); + + socket.on( 'newReaction', ( { messageId, reaction_data } ) => { + setMessages( prev => { + if (!Array.isArray( prev )) return prev; + return prev.map( m => m.id === parseInt( messageId ) ? { + ...m, + reactions:[...(Array.isArray( m.reactions ) ? Array.from( new Set( m.reactions ) ) : []), reaction_data] + } : m ); + } ); + } ); + + return () => { + socket.off( 'newMessage' ); + socket.off( 'newReaction' ); + }; + } + }, [user] ); + + useEffect( () => { + messagesEndRef.current?.scrollIntoView( { behavior:'smooth' } ); + }, [messages] ); + + /** + * Select a conversation to view messages + * @param {Object} convo - Conversation object + */ + const selectConversation = ( convo ) => { + setSelectedConversation( convo ); + setMessages( + Array.isArray( messages ) + ? messages.filter( m => + (convo.type === 'group' && m.group_id === convo.targetId) || + (convo.type === 'user' && (m.recipient_id === convo.targetId || m.sender_id === convo.targetId)) + ) + : [] + ); + setIsSidebarOpen( false ); + }; + + /** + * Send a message with optional file attachments + */ + const sendMessage = async () => { + if (content.trim() || attachedFiles.length) { + let fileIds = []; + try { + if (attachedFiles.length) { + const formData = new FormData(); + attachedFiles.forEach( f => formData.append( 'files', f ) ); + formData.append( 'visibility', selectedConversation?.type === 'group' ? 'family' : selectedConversation?.type === 'user' ? 'specific' : 'family' ); + if (selectedConversation?.type === 'user') formData.append( 'specific_users', JSON.stringify( [selectedConversation.targetId] ) ); + const res = await axios.post( `${ API_BASE_URL }/file/create`, formData, { + headers:{ 'Content-Type':'multipart/form-data' }, + withCredentials:true + } ); + fileIds = Array.isArray( res.data ) ? res.data.map( f => f.id ) : [res.data.id]; + } + socket.emit( 'chatMessage', { + content, + recipientId:selectedConversation?.type === 'user' ? selectedConversation.targetId : null, + groupId:selectedConversation?.type === 'group' ? selectedConversation.targetId : null, + fileIds + } ); + setContent( '' ); + setAttachedFiles( [] ); + } catch (err) { + console.error( 'Send message error:', err ); + setError( 'Failed to send message' ); + } + } + }; + + if (!user) { + return
Please log in to access chat
; + } + + if (error) { + return
{ error }
; + } + + /** + * + * @returns {React.ReactNode[]} + */ + const getConvos = () => ( + conversations.map( convo => ( +
selectConversation( convo ) } + className={ `p-2 mb-2 rounded cursor-pointer ${ selectedConversation?.id === convo.id ? 'bg-blue-200' : 'hover:bg-gray-200' }` }> +

{ convo.name } ({ convo.type === 'group' ? 'Group' : 'Private' })

+

{ convo.lastMessage }

+

{ new Date( convo.lastMessageTime ).toLocaleString() }

+
+ ) ) + ); + + /** + * + * @returns {React.ReactNode[]} + */ + const getMessages = () => ( + messages.map( ( msg, i ) => ( + +
+ +
+
+ ) ) + ); + + /** + * Handles file selection and validation + * @param {React.ChangeEvent} event + */ + const handleFileChange = (event) => { + const files = Array.from(event.target.files); + const validMedia = files + .map((file) => ({ + file, + type: file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : null, + })) + .filter((media) => { + if (!media.type) { + alert('Please select image or video files only.'); + return false; + } + if (media.file.size > maxSizeMB * 1024 * 1024) { + alert(`File size exceeds ${maxSizeMB}MB.`); + return false; + } + return true; + }); + + setSelectedMedia(validMedia); + + // Generate previews + const newPreviews = validMedia.map((media) => URL.createObjectURL(media.file)); + setPreviews(newPreviews); + + // Trigger upload if provided + // if (onUpload && validMedia.length > 0) { + // uploadFiles(validMedia.map((media) => media.file)); + // } + }; + + /** + * Removes a file from the selection + * @param {number} index + */ + const removeFile = (index) => { + setSelectedMedia((prev) => prev.filter((_, i) => i !== index)); + setPreviews((prev) => { + const newPreviews = [...prev]; + URL.revokeObjectURL(newPreviews[index]); // Free memory + newPreviews.splice(index, 1); + return newPreviews; + }); + }; + + return ( + + { isSidebarOpen && + { getConvos() } + } + + + +

+ { selectedConversation ? `${ selectedConversation.name } (${ selectedConversation.type === 'group' ? 'Group' : 'Private' })` : 'Select a conversation' } +

+ +
}/> + +
+ { selectedConversation ? ( + messages.length === 0 ? ( +

No messages in this conversation

+ ) : ( + + { getMessages() } + + ) + ) : ( +

Select a conversation to start chatting

+ ) } +
+
+ + {/* Progress Bar */} + {progress > 0 && ( + + )} + { selectedConversation && previews.length > 0 && ( + + {previews.map((preview, index) => ( + + {selectedMedia[index].type === 'image' ? ( + + ) : ( + + )} + removeFile(index)} + sx={{ position: 'absolute', top: 4, right: 4 }} + /> + + ))} + + ) } + + + handleFileChange(e) } + className="mb-2"/> + { selectedConversation ? ( + setContent( e.target.value ) } + placeholder="Type a message" + fullWidth + slotProps={ { + input:{ + startAdornment: + + + , + endAdornment: + + + + } + } }/> + + /*
+ setAttachedFiles( Array.from( e.target.files ) ) } + className="mb-2"/> +
+ setContent( e.target.value ) } + placeholder="Type a message" + className="border p-2 flex-grow rounded-l"/> + +
+
*/ + ) : null } +
+ + + + ); +}; + +export default Chat; \ No newline at end of file