import React, { useState, useEffect, useRef } from 'react'; import { io, Socket } from 'socket.io-client'; import { Send, MessageSquare, Loader2, Image as ImageIcon, MapPin, Download, X } from 'lucide-react'; import { useNotification } from '@/hooks/useNotification'; interface TourChatProps { tourId: string; embedded?: boolean; } export const TourChat: React.FC = ({ tourId, embedded = false }) => { const notify = useNotification(); const [messages, setMessages] = useState([]); const [newMessage, setNewMessage] = useState(''); const [loading, setLoading] = useState(true); const [selectedImage, setSelectedImage] = useState(null); const [imagePreview, setImagePreview] = useState(null); const [attachedLocation, setAttachedLocation] = useState<{ latitude: number; longitude: number } | null>(null); const [isUploading, setIsUploading] = useState(false); const [isLocating, setIsLocating] = useState(false); const [participants, setParticipants] = useState([]); const [showMentionList, setShowMentionList] = useState(false); const [mentionSearch, setMentionSearch] = useState(''); const [mentionIndex, setMentionIndex] = useState(0); const [taggedUserIds, setTaggedUserIds] = useState([]); const messagesEndRef = useRef(null); const socketRef = useRef(null); const fileInputRef = useRef(null); const inputRef = useRef(null); const mentionRef = useRef(null); const getHeaders = () => ({ 'Authorization': `Bearer ${localStorage.getItem('token')}`, 'Content-Type': 'application/json' }); const currentUserId = (() => { try { const token = localStorage.getItem('token'); if (!token) return null; const base64Url = token.split('.')[1]; const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); const jsonPayload = decodeURIComponent(atob(base64).split('').map(function(c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2); }).join('')); const parsed = JSON.parse(jsonPayload); return parsed.sub || parsed.id; } catch (e) { return null; } })(); // Fetch tour details to get participants (excluding current user) useEffect(() => { const fetchTourDetails = async () => { try { const res = await fetch(`/api/v1/tours/${tourId}`, { headers: getHeaders() }); if (res.ok) { const data = await res.json(); if (data && data.participants) { const memberList = data.participants .map((p: any) => p.user) .filter((u: any) => u && u.id !== currentUserId); setParticipants(memberList); } } } catch (err) { console.error('Lỗi khi tải thông tin thành viên tour:', err); } }; if (tourId && currentUserId) { fetchTourDetails(); } }, [tourId, currentUserId]); // Click outside to close mention dropdown useEffect(() => { const handleOutsideClick = (e: MouseEvent) => { if (mentionRef.current && !mentionRef.current.contains(e.target as Node)) { setShowMentionList(false); } }; document.addEventListener('mousedown', handleOutsideClick); return () => document.removeEventListener('mousedown', handleOutsideClick); }, []); const filteredParticipants = participants.filter(p => p.name.toLowerCase().includes(mentionSearch.toLowerCase()) ); const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; setNewMessage(value); const selectionStart = e.target.selectionStart || 0; const textBeforeCursor = value.slice(0, selectionStart); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex !== -1) { const textAfterAt = textBeforeCursor.slice(lastAtIndex + 1); if (!textAfterAt.includes(' ')) { setShowMentionList(true); setMentionSearch(textAfterAt); setMentionIndex(0); return; } } setShowMentionList(false); }; const handleKeyDown = (e: React.KeyboardEvent) => { if (!showMentionList) return; const filtered = filteredParticipants; if (filtered.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setMentionIndex(prev => (prev + 1) % filtered.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setMentionIndex(prev => (prev - 1 + filtered.length) % filtered.length); } else if (e.key === 'Enter') { e.preventDefault(); insertMention(filtered[mentionIndex]); } else if (e.key === 'Escape') { e.preventDefault(); setShowMentionList(false); } }; const insertMention = (member: { id: string; name: string }) => { const input = inputRef.current; if (!input) return; const selectionStart = input.selectionStart || 0; const textBeforeCursor = newMessage.slice(0, selectionStart); const textAfterCursor = newMessage.slice(selectionStart); const lastAtIndex = textBeforeCursor.lastIndexOf('@'); if (lastAtIndex !== -1) { const newTextBeforeCursor = textBeforeCursor.slice(0, lastAtIndex) + `@${member.name} `; const updatedValue = newTextBeforeCursor + textAfterCursor; setNewMessage(updatedValue); setShowMentionList(false); if (!taggedUserIds.includes(member.id)) { setTaggedUserIds(prev => [...prev, member.id]); } setTimeout(() => { input.focus(); const cursorPosition = newTextBeforeCursor.length; input.setSelectionRange(cursorPosition, cursorPosition); }, 0); } }; // Fetch past messages useEffect(() => { const fetchMessages = async () => { try { const res = await fetch(`/api/v1/tours/${tourId}/messages`, { headers: getHeaders() }); if (res.ok) { const data = await res.json(); setMessages(data || []); } } catch (err) { console.error('Lỗi khi tải tin nhắn:', err); } finally { setLoading(false); } }; fetchMessages(); }, [tourId]); // Connect to socket and listen for tour messages useEffect(() => { const socket = io(); socketRef.current = socket; socket.on('connect', () => { socket.emit('joinTour', tourId); }); socket.on('tourMessageReceived', (data: any) => { if (data.tourId === tourId) { setMessages(prev => [...prev, data.message]); } }); return () => { socket.disconnect(); }; }, [tourId]); // Autoscroll chat to bottom useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); // Compress image to 2K (max 2048px longest side) const compressImageTo2K = (file: File): Promise => { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.readAsDataURL(file); reader.onload = (event) => { const img = new Image(); img.src = event.target?.result as string; img.onload = () => { const MAX_DIM = 2048; let width = img.width; let height = img.height; if (width > MAX_DIM || height > MAX_DIM) { if (width > height) { height = Math.round((height * MAX_DIM) / width); width = MAX_DIM; } else { width = Math.round((width * MAX_DIM) / height); height = MAX_DIM; } } const canvas = document.createElement('canvas'); canvas.width = width; canvas.height = height; const ctx = canvas.getContext('2d'); if (!ctx) { resolve(file); return; } ctx.drawImage(img, 0, 0, width, height); canvas.toBlob( (blob) => { if (blob) { resolve(blob); } else { resolve(file); } }, 'image/jpeg', 0.85 ); }; img.onerror = (err) => reject(err); }; reader.onerror = (err) => reject(err); }); }; // Handle Image Selection const handleImageChange = (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (file) { setSelectedImage(file); setImagePreview(URL.createObjectURL(file)); } }; // Handle Location Sharing const handleGetLocation = () => { if (!navigator.geolocation) { notify({ title: 'Không hỗ trợ', message: 'Trình duyệt của bạn không hỗ trợ định vị GPS.', type: 'error' }); return; } setIsLocating(true); navigator.geolocation.getCurrentPosition( (position) => { setAttachedLocation({ latitude: position.coords.latitude, longitude: position.coords.longitude }); notify({ title: 'Gắn vị trí thành công', message: 'Vị trí hiện tại đã được đính kèm vào tin nhắn.', type: 'success' }); setIsLocating(false); }, (error) => { console.error('Lỗi định vị:', error); notify({ title: 'Lỗi GPS', message: 'Không thể lấy vị trí hiện tại của bạn. Hãy kiểm tra quyền truy cập.', type: 'error' }); setIsLocating(false); }, { enableHighAccuracy: true, timeout: 10000 } ); }; // Upload image to backend const uploadImage = async (file: File): Promise => { try { setIsUploading(true); // Compress first const compressedBlob = await compressImageTo2K(file); const formData = new FormData(); formData.append('image', compressedBlob, 'compressed.jpg'); const res = await fetch('/api/v1/upload', { method: 'POST', headers: { 'Authorization': `Bearer ${localStorage.getItem('token')}` }, body: formData }); if (res.ok) { const data = await res.json(); return data.url; } return null; } catch (err) { console.error('Lỗi upload ảnh:', err); return null; } finally { setIsUploading(false); } }; // Send Tour Message const handleSendMessage = async (e?: React.FormEvent) => { if (e) e.preventDefault(); if (!newMessage.trim() && !selectedImage && !attachedLocation) return; let attachmentUrl = undefined; if (selectedImage) { attachmentUrl = await uploadImage(selectedImage); if (!attachmentUrl) { notify({ title: 'Lỗi', message: 'Không thể tải ảnh đính kèm lên server.', type: 'error' }); return; } } const actualTaggedUserIds = taggedUserIds.filter(userId => { const member = participants.find(p => p.id === userId); if (!member || !member.name) return false; const cleanMessage = newMessage.toLowerCase(); const nameLower = member.name.toLowerCase(); // Try exact match first if (cleanMessage.includes(`@${nameLower}`)) return true; // Try match without parentheses (e.g. "Lộc Phạm (Chủ Tour)" -> "Lộc Phạm") const nameWithoutParentheses = member.name.split('(')[0].trim().toLowerCase(); if (nameWithoutParentheses && cleanMessage.includes(`@${nameWithoutParentheses}`)) return true; return false; }); const payload = { content: newMessage, attachmentUrl, latitude: attachedLocation?.latitude, longitude: attachedLocation?.longitude, taggedUserIds: actualTaggedUserIds }; // Reset input fields immediately setNewMessage(''); setSelectedImage(null); setImagePreview(null); setAttachedLocation(null); setTaggedUserIds([]); try { const res = await fetch(`/api/v1/tours/${tourId}/messages`, { method: 'POST', headers: getHeaders(), body: JSON.stringify(payload) }); if (!res.ok) { notify({ title: 'Lỗi', message: 'Gửi tin nhắn thất bại.', type: 'error' }); } } catch (err) { console.error('Lỗi gửi tin nhắn:', err); } }; // Download image file helper const handleDownloadImage = async (url: string, id: string) => { try { const response = await fetch(url); const blob = await response.blob(); const blobUrl = window.URL.createObjectURL(blob); const link = document.createElement('a'); link.href = blobUrl; link.download = `tour-chat-photo-${id}.jpg`; document.body.appendChild(link); link.click(); document.body.removeChild(link); window.URL.revokeObjectURL(blobUrl); } catch (error) { console.error('Lỗi tải ảnh:', error); window.open(url, '_blank'); } }; return (
{/* Fixed Top Layout Block - Header and Tabs Container */}
{/* Chat Header - Only show when not embedded */} {!embedded && (

Trò chuyện nhóm hành trình

Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên

)}
{/* Chat History Scroll Viewport - ONLY scrollable area */}
{loading ? (
Đang tải tin nhắn...
) : messages.length === 0 ? (
Chưa có tin nhắn nào trong phòng chat nhóm này.
) : ( messages.map((msg) => { const isMe = msg.senderId === currentUserId; const initials = msg.sender?.name ? msg.sender.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase() : 'U'; return (
{!isMe && (
{msg.sender?.avatar ? ( {msg.sender.name} ) : initials}
)}
{!isMe && ( {msg.sender?.name || 'Thành viên'} )}
{/* Attachment Image */} {msg.attachmentUrl && (
Đính kèm
)} {/* GPS Location badge */} {msg.latitude !== undefined && msg.latitude !== null && (
Vị trí hiện tại {msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}
)} {/* Content text */} {msg.content &&

{msg.content}

}
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
); }) )}
{/* Previews (Image & GPS Location) */} {(imagePreview || attachedLocation) && (
{imagePreview && (
Preview
)} {attachedLocation && (
Đã đính kèm GPS
)}
)} {/* Locked Chat Input Footer */}
{/* Mention list dropdown */} {showMentionList && filteredParticipants.length > 0 && (
{filteredParticipants.map((member, index) => ( ))}
)} {/* Chat Input form */}
{/* Attach photo button */} {/* Share current GPS button */}
); };