660 lines
24 KiB
TypeScript
660 lines
24 KiB
TypeScript
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<TourChatProps> = ({ tourId, embedded = false }) => {
|
|
const notify = useNotification();
|
|
const [messages, setMessages] = useState<any[]>([]);
|
|
const [newMessage, setNewMessage] = useState('');
|
|
const [loading, setLoading] = useState(true);
|
|
const [selectedImage, setSelectedImage] = useState<File | null>(null);
|
|
const [imagePreview, setImagePreview] = useState<string | null>(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<any[]>([]);
|
|
const [showMentionList, setShowMentionList] = useState(false);
|
|
const [mentionSearch, setMentionSearch] = useState('');
|
|
const [mentionIndex, setMentionIndex] = useState(0);
|
|
const [taggedUserIds, setTaggedUserIds] = useState<string[]>([]);
|
|
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const socketRef = useRef<Socket | null>(null);
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const mentionRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
|
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<HTMLInputElement>) => {
|
|
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<Blob> => {
|
|
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<HTMLInputElement>) => {
|
|
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<string | null> => {
|
|
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 (
|
|
<div className={`chat-viewport-wrapper flex flex-col !overflow-hidden !w-full ${embedded ? 'h-full flex-1' : 'h-[100dvh]'} bg-white`}>
|
|
{/* Fixed Top Layout Block - Header and Tabs Container */}
|
|
<div className="fixed-top-layout-block flex-shrink-0 !w-full z-50 bg-white border-b border-gray-200">
|
|
{/* Chat Header - Only show when not embedded */}
|
|
{!embedded && (
|
|
<div className="p-4 border-b border-gray-200 bg-white flex items-center gap-2">
|
|
<MessageSquare className="w-5 h-5 text-blue-500" />
|
|
<div>
|
|
<h3 className="text-sm font-bold text-gray-800">Trò chuyện nhóm hành trình</h3>
|
|
<p className="text-[10px] text-gray-400 font-medium">Nơi trao đổi thông tin, hình ảnh và định vị giữa các thành viên</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Chat History Scroll Viewport - ONLY scrollable area */}
|
|
<div className="chat-history-scroll-viewport flex-1 min-h-0 !overflow-y-auto !overflow-x-hidden p-4 flex flex-col gap-3 bg-slate-50/20">
|
|
{loading ? (
|
|
<div className="flex-1 flex items-center justify-center text-gray-400 text-xs gap-1.5">
|
|
<Loader2 className="w-4 h-4 animate-spin text-blue-500" /> Đang tải tin nhắn...
|
|
</div>
|
|
) : messages.length === 0 ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-gray-450 text-xs italic gap-1.5">
|
|
<MessageSquare className="w-8 h-8 text-gray-300" />
|
|
<span className="text-gray-400">Chưa có tin nhắn nào trong phòng chat nhóm này.</span>
|
|
</div>
|
|
) : (
|
|
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 (
|
|
<div
|
|
key={msg.id}
|
|
className={`flex gap-2 max-w-[80%] ${isMe ? 'self-end flex-row-reverse' : 'self-start'}`}
|
|
>
|
|
{!isMe && (
|
|
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-500 to-indigo-500 flex items-center justify-center font-bold text-[10px] text-white shadow-sm shrink-0">
|
|
{msg.sender?.avatar ? (
|
|
<img src={msg.sender.avatar} alt={msg.sender.name} className="w-full h-full rounded-full object-cover" />
|
|
) : initials}
|
|
</div>
|
|
)}
|
|
<div className={`flex flex-col ${isMe ? 'items-end' : 'items-start'}`}>
|
|
{!isMe && (
|
|
<span className="text-[10px] font-bold text-gray-500 mb-0.5 ml-1">
|
|
{msg.sender?.name || 'Thành viên'}
|
|
</span>
|
|
)}
|
|
<div className={`p-3 rounded-2xl text-xs font-medium leading-relaxed flex flex-col gap-1.5 relative group ${
|
|
isMe
|
|
? 'bg-blue-600 text-white rounded-tr-none'
|
|
: 'bg-white text-gray-700 rounded-tl-none border border-gray-200 shadow-sm'
|
|
}`}>
|
|
{/* Attachment Image */}
|
|
{msg.attachmentUrl && (
|
|
<div className="relative rounded-lg overflow-hidden border border-black/5 max-w-xs group/img">
|
|
<img
|
|
src={msg.attachmentUrl}
|
|
alt="Đính kèm"
|
|
className="w-full max-h-48 object-cover hover:brightness-95 transition-all"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => handleDownloadImage(msg.attachmentUrl, msg.id)}
|
|
className="absolute bottom-2 right-2 p-1.5 bg-black/60 hover:bg-black/80 text-white rounded-md transition-all shadow-md flex items-center justify-center"
|
|
title="Tải ảnh này về máy"
|
|
>
|
|
<Download className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* GPS Location badge */}
|
|
{msg.latitude !== undefined && msg.latitude !== null && (
|
|
<a
|
|
href={`https://www.google.com/maps/search/?api=1&query=${msg.latitude},${msg.longitude}`}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-xl text-[11px] font-bold transition-all border ${
|
|
isMe
|
|
? 'bg-blue-700 border-blue-600 text-blue-100 hover:bg-blue-800'
|
|
: 'bg-gray-100 border-gray-200 text-gray-700 hover:bg-gray-200'
|
|
}`}
|
|
>
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500 shrink-0 animate-bounce" />
|
|
<div className="flex flex-col text-left">
|
|
<span>Vị trí hiện tại</span>
|
|
<span className="text-[9px] opacity-75">{msg.latitude.toFixed(6)}, {msg.longitude.toFixed(6)}</span>
|
|
</div>
|
|
</a>
|
|
)}
|
|
|
|
{/* Content text */}
|
|
{msg.content && <p className="whitespace-pre-wrap break-words">{msg.content}</p>}
|
|
</div>
|
|
<span className="text-[8px] text-gray-400 font-bold mt-1 px-1">
|
|
{new Date(msg.createdAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})
|
|
)}
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Previews (Image & GPS Location) */}
|
|
{(imagePreview || attachedLocation) && (
|
|
<div className="px-4 py-2 border-t border-gray-200 bg-gray-50/80 flex flex-wrap gap-2 !flex-shrink-0">
|
|
{imagePreview && (
|
|
<div className="relative w-16 h-16 rounded-lg overflow-hidden border border-gray-200 shadow-sm">
|
|
<img src={imagePreview} alt="Preview" className="w-full h-full object-cover" />
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedImage(null);
|
|
setImagePreview(null);
|
|
}}
|
|
className="absolute top-0.5 right-0.5 p-0.5 bg-black/60 hover:bg-black text-white rounded-full transition-all"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
{attachedLocation && (
|
|
<div className="flex items-center gap-1.5 bg-rose-50 border border-rose-200 rounded-lg px-2.5 py-1 text-xs text-rose-700 font-bold">
|
|
<MapPin className="w-3.5 h-3.5 text-rose-500 animate-pulse" />
|
|
<span>Đã đính kèm GPS</span>
|
|
<button
|
|
type="button"
|
|
onClick={() => setAttachedLocation(null)}
|
|
className="hover:text-rose-950 transition-colors ml-1"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Locked Chat Input Footer */}
|
|
<div className="locked-chat-input-footer !flex-shrink-0 !w-full !border-t border-gray-200 bg-white !z-40" style={{ paddingBottom: 'env(safe-area-inset-bottom, 12px)' }}>
|
|
{/* Mention list dropdown */}
|
|
{showMentionList && filteredParticipants.length > 0 && (
|
|
<div
|
|
ref={mentionRef}
|
|
className="absolute bottom-full left-3 right-3 mb-2 bg-white border border-gray-200 rounded-xl shadow-xl max-h-40 overflow-y-auto z-50 flex flex-col py-1"
|
|
>
|
|
{filteredParticipants.map((member, index) => (
|
|
<button
|
|
key={member.id}
|
|
type="button"
|
|
onClick={() => insertMention(member)}
|
|
className={`px-3 py-2 text-left text-xs font-semibold flex items-center gap-2 transition-colors ${
|
|
index === mentionIndex
|
|
? 'bg-blue-50 text-blue-700'
|
|
: 'text-gray-700 hover:bg-gray-50'
|
|
}`}
|
|
>
|
|
<div className="w-5 h-5 rounded-full bg-blue-100 flex items-center justify-center font-bold text-[9px] text-blue-600">
|
|
{member.name.split(' ').map((n: string) => n[0]).slice(0, 2).join('').toUpperCase()}
|
|
</div>
|
|
<span>{member.name}</span>
|
|
<span className="text-[10px] text-gray-400 font-medium font-mono">@{member.name}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Chat Input form */}
|
|
<form
|
|
onSubmit={handleSendMessage}
|
|
className="p-3 flex gap-2 items-center !w-full relative"
|
|
>
|
|
<input
|
|
type="file"
|
|
accept="image/*"
|
|
ref={fileInputRef}
|
|
onChange={handleImageChange}
|
|
className="hidden"
|
|
/>
|
|
|
|
{/* Attach photo button */}
|
|
<button
|
|
type="button"
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0"
|
|
title="Đính kèm hình ảnh"
|
|
>
|
|
<ImageIcon className="w-4 h-4 text-blue-500" />
|
|
</button>
|
|
|
|
{/* Share current GPS button */}
|
|
<button
|
|
type="button"
|
|
onClick={handleGetLocation}
|
|
disabled={isLocating}
|
|
className={`p-2.5 bg-white border border-gray-200 hover:bg-gray-50 text-gray-500 rounded-xl transition-all shadow-sm active:scale-95 flex items-center justify-center shrink-0 ${
|
|
isLocating ? 'animate-pulse' : ''
|
|
}`}
|
|
title="Chia sẻ vị trí GPS hiện tại"
|
|
>
|
|
{isLocating ? (
|
|
<Loader2 className="w-4 h-4 animate-spin text-rose-500" />
|
|
) : (
|
|
<MapPin className="w-4 h-4 text-rose-500" />
|
|
)}
|
|
</button>
|
|
|
|
<input
|
|
type="text"
|
|
ref={inputRef}
|
|
value={newMessage}
|
|
onChange={handleInputChange}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder={isUploading ? "Đang tải ảnh lên..." : "Nhập nội dung tin nhắn..."}
|
|
disabled={isUploading}
|
|
className="flex-1 bg-white border border-gray-200 rounded-xl py-2.5 px-3 text-xs text-gray-800 placeholder-gray-400 outline-none focus:border-blue-500 transition-all shadow-inner disabled:bg-gray-100 disabled:cursor-not-allowed"
|
|
/>
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={isUploading || (!newMessage.trim() && !selectedImage && !attachedLocation)}
|
|
className="p-2.5 bg-blue-600 hover:bg-blue-700 text-white rounded-xl transition-all shadow-md active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed shrink-0"
|
|
>
|
|
{isUploading ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<Send className="w-4 h-4" />
|
|
)}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|