import * as Lucide from 'https://esm.sh/lucide-react@0.263.1'; const { Send, UserPlus, LogOut, MessageCircle, Phone, Users, Settings, Plus, Search, ChevronRight, Copy, Share, Edit2, Info, ChevronLeft, PhoneCall, Video, Mic, Paperclip, Camera, Lock, Check, CheckCheck, Grid, Heart, X, Star, User, FileText, MicOff, VideoOff, Eye, Trash2, Shield, Ban, Key, Smartphone, Monitor } = Lucide; const { useState, useEffect, useRef, useMemo } = React; const API_BASE = 'api.php'; // E2EE Utilities const CryptoUtil = { generateKeyPair: async () => { return await window.crypto.subtle.generateKey( { name: "RSA-OAEP", modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: "SHA-256" }, true, ["encrypt", "decrypt"] ); }, exportPublicKey: async (key) => { const exported = await window.crypto.subtle.exportKey("spki", key); return btoa(String.fromCharCode(...new Uint8Array(exported))); }, exportPrivateKey: async (key) => { const exported = await window.crypto.subtle.exportKey("pkcs8", key); return btoa(String.fromCharCode(...new Uint8Array(exported))); }, importPublicKey: async (b64) => { const binaryDer = atob(b64); const buf = new Uint8Array(binaryDer.length); for (let i = 0; i < binaryDer.length; i++) buf[i] = binaryDer.charCodeAt(i); return await window.crypto.subtle.importKey("spki", buf, { name: "RSA-OAEP", hash: "SHA-256" }, true, ["encrypt"]); }, importPrivateKey: async (b64) => { const binaryDer = atob(b64); const buf = new Uint8Array(binaryDer.length); for (let i = 0; i < binaryDer.length; i++) buf[i] = binaryDer.charCodeAt(i); return await window.crypto.subtle.importKey("pkcs8", buf, { name: "RSA-OAEP", hash: "SHA-256" }, true, ["decrypt"]); }, generateAESKey: async () => { return await window.crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"]); }, encryptAES: async (text, aesKey) => { const iv = window.crypto.getRandomValues(new Uint8Array(12)); const encoded = new TextEncoder().encode(text); const ciphertext = await window.crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded); return { iv: btoa(String.fromCharCode(...new Uint8Array(iv))), ciphertext: btoa(String.fromCharCode(...new Uint8Array(ciphertext))) }; }, decryptAES: async (ivB64, ciphertextB64, aesKey) => { const iv = new Uint8Array(atob(ivB64).split('').map(c => c.charCodeAt(0))); const ciphertext = new Uint8Array(atob(ciphertextB64).split('').map(c => c.charCodeAt(0))); const decrypted = await window.crypto.subtle.decrypt({ name: "AES-GCM", iv }, aesKey, ciphertext); return new TextDecoder().decode(decrypted); }, encryptKeyRSA: async (aesKey, rsaPublicKey) => { const rawAes = await window.crypto.subtle.exportKey("raw", aesKey); const encrypted = await window.crypto.subtle.encrypt({ name: "RSA-OAEP" }, rsaPublicKey, rawAes); return btoa(String.fromCharCode(...new Uint8Array(encrypted))); }, decryptKeyRSA: async (encryptedAesB64, rsaPrivateKey) => { const encrypted = new Uint8Array(atob(encryptedAesB64).split('').map(c => c.charCodeAt(0))); const rawAes = await window.crypto.subtle.decrypt({ name: "RSA-OAEP" }, rsaPrivateKey, encrypted); return await window.crypto.subtle.importKey("raw", rawAes, { name: "AES-GCM" }, true, ["encrypt", "decrypt"]); }, deriveAESKeyFromPassword: async (passwordStr) => { const enc = new TextEncoder(); const keyMaterial = await window.crypto.subtle.importKey( "raw", enc.encode(passwordStr), "PBKDF2", false, ["deriveBits", "deriveKey"] ); return await window.crypto.subtle.deriveKey( { name: "PBKDF2", salt: enc.encode("LUTOS_SALT"), iterations: 100000, hash: "SHA-256" }, keyMaterial, { name: "AES-GCM", length: 256 }, true, ["encrypt", "decrypt"] ); } }; const LinkPreview = ({ url, isMe }) => { const [preview, setPreview] = useState(null); const [loading, setLoading] = useState(true); useEffect(() => { let active = true; const fetchPreview = async () => { try { const cached = sessionStorage.getItem(`lp_${url}`); if (cached) { if (active) { setPreview(JSON.parse(cached)); setLoading(false); } return; } const res = await axios.get(`api.php?action=link_preview&url=${encodeURIComponent(url)}`); if (res.data && !res.data.error) { sessionStorage.setItem(`lp_${url}`, JSON.stringify(res.data)); if (active) setPreview(res.data); } } catch (err) { console.error("Link preview error:", err); } finally { if (active) setLoading(false); } }; fetchPreview(); return () => { active = false; }; }, [url]); if (loading) { return (
); } if (!preview || (!preview.title && !preview.image)) return null; return ( {preview.image && (
Preview
)}
{preview.title &&
{preview.title}
} {preview.description &&
{preview.description}
}
{new URL(url).hostname}
); }; function App() { // === CORE STATES === const [user, setUser] = useState(() => { try { const saved = localStorage.getItem('chat_user'); return saved ? JSON.parse(saved) : null; } catch (e) { return null; } }); const userRef = useRef(null); const [nameInput, setNameInput] = useState(''); const [acceptedTerms, setAcceptedTerms] = useState(false); const [passwordInput, setPasswordInput] = useState(''); const [loginError, setLoginError] = useState(''); const [isLoginMode, setIsLoginMode] = useState(false); const [friendIdInput, setFriendIdInput] = useState(''); const [friends, setFriends] = useState([]); const [activeChat, setActiveChat] = useState(null); const [messages, setMessages] = useState([]); const [messageInput, setMessageInput] = useState(''); // === UI STATES === const [activeTab, setActiveTab] = useState('chats'); // 'chats', 'calls', 'contacts', 'settings' const [adminSubTab, setAdminSubTab] = useState(null); // 'list', 'actions', 'banned' const [callHistory, setCallHistory] = useState([]); const [callFilter, setCallFilter] = useState('all'); // 'all', 'missed' const [savedAccounts, setSavedAccounts] = useState([]); const [isMobile, setIsMobile] = useState(window.innerWidth < 768); const [chatSettings, setChatSettings] = useState({}); const [showContactInfo, setShowContactInfo] = useState(false); const [showLogoutModal, setShowLogoutModal] = useState(false); const [settingsSubScreen, setSettingsSubScreen] = useState(null); // null | 'profile' | 'privacy' const [onlineStatus, setOnlineStatus] = useState(true); const [typingStatus, setTypingStatus] = useState(true); const [seenStatus, setSeenStatus] = useState(true); const [showEditProfileModal, setShowEditProfileModal] = useState(false); const [editName, setEditName] = useState(''); const [editBio, setEditBio] = useState(''); const [editAvatarUrl, setEditAvatarUrl] = useState(''); const [isSavingProfile, setIsSavingProfile] = useState(false); const [isChatEditMode, setIsChatEditMode] = useState(false); const [selectedChatIds, setSelectedChatIds] = useState([]); const [showMessageSearch, setShowMessageSearch] = useState(false); const [messageSearchQuery, setMessageSearchQuery] = useState(''); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); const [changePasswordError, setChangePasswordError] = useState(''); const [isChangingPassword, setIsChangingPassword] = useState(false); const [viewingImage, setViewingImage] = useState(null); // State để xem ảnh fullscreen const [pinnedFriends, setPinnedFriends] = useState([]); // Ghim bạn bè const [unreadMessages, setUnreadMessages] = useState({}); // Thông báo chưa đọc const [pinnedMessages, setPinnedMessages] = useState({}); // Ghim tin nhắn const [contextMenu, setContextMenu] = useState(null); // Menu chuột phải: { x, y, msg } const [listContextMenu, setListContextMenu] = useState(null); // Menu chuột phải danh sách: { x, y, friend } const [deletingMsgIds, setDeletingMsgIds] = useState([]); // Array of msg ids currently animating deletion const [contactSubView, setContactSubView] = useState('profile'); // profile hoặc media const [mediaActiveTab, setMediaActiveTab] = useState('media'); // 'media' | 'links' const [showNewChatModal, setShowNewChatModal] = useState(false); // Modal nút + const [showDialpad, setShowDialpad] = useState(false); // Modal bàn phím số const [dialNumber, setDialNumber] = useState(''); // Số trên bàn phím const [toast, setToast] = useState(null); // { title: '', message: '' } const [showRecoveryCode, setShowRecoveryCode] = useState(null); const [showRecoveryInput, setShowRecoveryInput] = useState(false); const [recoveryInputVal, setRecoveryInputVal] = useState(''); const [pendingLoginUser, setPendingLoginUser] = useState(null); const [bannedUser, setBannedUser] = useState(null); const [notFoundError, setNotFoundError] = useState(false); // === PWA & NATIVE INSTALL STATES === const [deferredPrompt, setDeferredPrompt] = useState(null); const [isAppInstalled, setIsAppInstalled] = useState( window.matchMedia('(display-mode: standalone)').matches || window.navigator.standalone === true ); const isDeviceVerified = useMemo(() => { if (!user) return true; if (!user.encrypted_priv_key) return true; return !!localStorage.getItem(`privKey_${user.userId}`); }, [user]); // === HIDDEN CHATS STATES === const [hiddenChats, setHiddenChats] = useState([]); const [hiddenChatsPin, setHiddenChatsPin] = useState(''); const [showHiddenPinModal, setShowHiddenPinModal] = useState(false); const [hiddenPinModalMode, setHiddenPinModalMode] = useState('setup'); // 'setup' | 'confirm_setup' | 'enter_to_hide' | 'enter_to_manage' const [hiddenPinInputVal, setHiddenPinInputVal] = useState(''); const [hiddenPinTempSetup, setHiddenPinTempSetup] = useState(''); const [hiddenPinTargetChat, setHiddenPinTargetChat] = useState(null); const [hiddenPinError, setHiddenPinError] = useState(''); const [showHiddenChatsList, setShowHiddenChatsList] = useState(false); const [chatSearchQuery, setChatSearchQuery] = useState(''); useEffect(() => { if (user) { try { const saved = localStorage.getItem(`hiddenChats_${user.userId}`); setHiddenChats(saved ? JSON.parse(saved) : []); } catch (e) { setHiddenChats([]); } setHiddenChatsPin(localStorage.getItem(`hiddenChatsPin_${user.userId}`) || ''); } }, [user]); // === PWA & MOBILE INSTALL LISTENERS === useEffect(() => { const handleBeforeInstallPrompt = (e) => { e.preventDefault(); setDeferredPrompt(e); }; const handleAppInstalled = () => { setIsAppInstalled(true); setDeferredPrompt(null); showToast('Thành công', 'Lutos Chat đã được cài đặt làm ứng dụng di động!'); }; window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.addEventListener('appinstalled', handleAppInstalled); return () => { window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt); window.removeEventListener('appinstalled', handleAppInstalled); }; }, []); // === BLOCKED USERS STATES === const [blockedUsers, setBlockedUsers] = useState([]); // Array of { userId, name, avatarUrl } const [showBlockedUsersList, setShowBlockedUsersList] = useState(false); const fetchBlockedUsers = async () => { if (!user) return; try { const res = await axios.get(`${API_BASE}?action=get_blocked_users&userId=${user.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { setBlockedUsers(res.data); } else { setBlockedUsers([]); } } catch (e) { setBlockedUsers([]); } }; useEffect(() => { if (user) { fetchBlockedUsers(); } else { setBlockedUsers([]); } }, [user]); // === SECURE STORAGE PIN STATES === const [showPinSetupModal, setShowPinSetupModal] = useState(false); const [skippedPinSetup, setSkippedPinSetup] = useState(false); const [pinSetupInputVal, setPinSetupInputVal] = useState(''); const [isSavingPin, setIsSavingPin] = useState(false); const [isVerifying, setIsVerifying] = useState(false); const [pinError, setPinError] = useState(''); const [pinSetupStep, setPinSetupStep] = useState('enter_new'); // 'verify_current' | 'enter_new' const [currentPinAttempts, setCurrentPinAttempts] = useState(0); const [recoveryAttempts, setRecoveryAttempts] = useState(0); const [shouldShake, setShouldShake] = useState(false); const triggerPinError = (message) => { setPinError(message); setShouldShake(true); setTimeout(() => setShouldShake(false), 500); }; const renderAttemptsBadge = (attempts, isRecovery = false) => { const remaining = 5 - attempts; if (remaining === 5) { return ( {isRecovery ? 'Tối đa 5 lần nhập sai sẽ bị mất tin nhắn cũ' : 'Tối đa 5 lần nhập sai sẽ bị khóa'} ); } if (remaining === 4) { return ( Còn lại 4 lần nhập thử ); } if (remaining === 3) { return ( Còn lại 3 lần nhập thử ); } if (remaining === 2) { return ( Còn lại 2 lần nhập thử ); } // remaining === 1 return ( ⚠️ Còn lại 1 lần nhập thử cuối cùng! ); }; // === ADMIN STATES === const [adminUsers, setAdminUsers] = useState([]); // === GROUP STATES === const [showCreateGroupModal, setShowCreateGroupModal] = useState(false); const [groupNameInput, setGroupNameInput] = useState(''); const [selectedGroupMembers, setSelectedGroupMembers] = useState([]); const [myGroups, setMyGroups] = useState([]); const [isCreatingGroup, setIsCreatingGroup] = useState(false); const [loadingAdmin, setLoadingAdmin] = useState(false); const fetchAdminUsers = async () => { setLoadingAdmin(true); try { const res = await axios.get(`${API_BASE}?action=admin_get_users&requesterId=${user?.userId}&t=${Date.now()}`); setAdminUsers(res.data); } catch (e) { console.error(e); } setLoadingAdmin(false); }; useEffect(() => { if (activeTab === 'admin' && user?.is_admin == 1) { fetchAdminUsers(); } }, [activeTab]); const handleBanUser = async (targetUserId, currentStatus) => { let banReason = null; if (currentStatus != 1) { // If we are banning (not unbanning) banReason = window.prompt("Vui lòng nhập lý do khóa tài khoản này:"); if (banReason === null) return; // User clicked cancel } try { await axios.post(`${API_BASE}?action=admin_ban_user`, { requesterId: user.userId, targetUserId, banStatus: currentStatus == 1 ? 0 : 1, banReason }); fetchAdminUsers(); showToast('Thành công', currentStatus == 1 ? 'Đã mở khóa tài khoản' : 'Đã khóa tài khoản'); } catch (e) { showToast('Lỗi', 'Có lỗi xảy ra'); } } const handleDeleteUser = async (targetUserId) => { if (!window.confirm("Xóa vĩnh viễn user này?")) return; try { await axios.post(`${API_BASE}?action=admin_delete_user`, { requesterId: user.userId, targetUserId }); fetchAdminUsers(); showToast('Thành công', 'Đã xóa tài khoản'); } catch (e) { showToast('Lỗi', 'Có lỗi xảy ra'); } } const toastTimeoutRef = useRef(null); const lastMsgTimesRef = useRef({}); // Lưu trữ thời gian tin nhắn cuối cùng để check tin nhắn mới // === REFS === const scrollRef = useRef(); const fileInputRef = useRef(null); const avatarInputRef = useRef(null); const wallpaperInputRef = useRef(null); const editAvatarInputRef = useRef(null); const dialpadLongPressTimer = useRef(null); const handlePasteToDialpad = async () => { try { const text = await navigator.clipboard.readText(); const cleaned = text.trim(); if (cleaned) { if (/^[\d-]+$/.test(cleaned)) { let digits = cleaned.replace(/-/g, ''); if (digits.length > 8) digits = digits.slice(0, 8); let formatted = digits; if (digits.length > 3) { formatted = digits.slice(0, 3) + '-' + digits.slice(3); } setDialNumber(formatted); } else { setDialNumber(cleaned); } } } catch (err) { console.error("Paste failed", err); const text = prompt("Dán hoặc nhập số Lutos Chat vào đây:"); if (text !== null) { const cleaned = text.trim(); if (cleaned) { if (/^[\d-]+$/.test(cleaned)) { let digits = cleaned.replace(/-/g, ''); if (digits.length > 8) digits = digits.slice(0, 8); let formatted = digits; if (digits.length > 3) { formatted = digits.slice(0, 3) + '-' + digits.slice(3); } setDialNumber(formatted); } else { setDialNumber(cleaned); } } } } }; const handlePasteToRecoveryInput = async () => { try { const text = await navigator.clipboard.readText(); const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { setRecoveryInputVal(cleaned.slice(0, 6)); setPinError(''); } } catch (err) { const text = prompt("Dán hoặc nhập mã PIN khôi phục 6 số của bạn vào đây:"); if (text !== null) { const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { setRecoveryInputVal(cleaned.slice(0, 6)); setPinError(''); } } } }; const handlePasteToPinSetupInput = async () => { try { const text = await navigator.clipboard.readText(); const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { setPinSetupInputVal(cleaned.slice(0, 6)); setPinError(''); } } catch (err) { const text = prompt("Dán hoặc nhập mã PIN mới 6 số của bạn vào đây:"); if (text !== null) { const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { setPinSetupInputVal(cleaned.slice(0, 6)); setPinError(''); } } } }; const handlePasteToHiddenPinInput = async () => { try { const text = await navigator.clipboard.readText(); const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { const val = cleaned.slice(0, 4); setHiddenPinInputVal(val); setHiddenPinError(''); if (val.length === 4) { setTimeout(() => handleHiddenPinSubmit(val), 200); } } } catch (err) { const text = prompt("Dán hoặc nhập mã PIN ẩn 4 số của bạn vào đây:"); if (text !== null) { const cleaned = text.trim().replace(/\D/g, ''); if (cleaned) { const val = cleaned.slice(0, 4); setHiddenPinInputVal(val); setHiddenPinError(''); if (val.length === 4) { setTimeout(() => handleHiddenPinSubmit(val), 200); } } } } }; const handleTouchStart = () => { dialpadLongPressTimer.current = setTimeout(() => { handlePasteToDialpad(); }, 600); }; const handleTouchEnd = () => { if (dialpadLongPressTimer.current) { clearTimeout(dialpadLongPressTimer.current); } }; // === MEDIA RECORDING === const [isRecording, setIsRecording] = useState(false); const mediaRecorderRef = useRef(null); const audioChunksRef = useRef([]); // === WEBRTC CALL STATES === const [callState, setCallState] = useState(null); const [incomingCall, setIncomingCall] = useState(null); const [localStream, setLocalStream] = useState(null); const [remoteStream, setRemoteStream] = useState(null); const [callDuration, setCallDuration] = useState(0); // Thời gian gọi (giây) const [isMicOn, setIsMicOn] = useState(true); // Trạng thái mic const [isCameraOn, setIsCameraOn] = useState(true); // Trạng thái camera const callTimerRef = useRef(null); // Timer đếm thời gian const localVideoRef = useRef(null); const remoteVideoRef = useRef(null); const remoteAudioRef = useRef(null); const ringtoneRef = useRef(null); // Âm thanh chuông reo const ringTimeoutRef = useRef(null); // Timer tự động tắt reo const peerConnection = useRef(null); const callStartTime = useRef(null); const pendingCandidates = useRef([]); useEffect(() => { const handleResize = () => setIsMobile(window.innerWidth < 768); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); useEffect(() => { const handleUnload = () => { if (callState) { const targetId = callState === 'incoming' ? incomingCall?.from : activeChat?.userId; if (targetId && user) { const data = JSON.stringify({ to_user: targetId, from_user: user.userId, type: 'end', signal_data: {} }); navigator.sendBeacon(`${API_BASE}?action=send_signal_beacon`, data); } } }; window.addEventListener('beforeunload', handleUnload); window.addEventListener('pagehide', handleUnload); return () => { window.removeEventListener('beforeunload', handleUnload); window.removeEventListener('pagehide', handleUnload); }; }, [callState, incomingCall, activeChat, user]); const showToast = (title, message) => { if (toastTimeoutRef.current) clearTimeout(toastTimeoutRef.current); setToast({ title, message }); toastTimeoutRef.current = setTimeout(() => { setToast(null); }, 4000); }; useEffect(() => { const handleDismiss = () => { setContextMenu(null); setListContextMenu(null); }; const handleKeyDown = (e) => { if (e.key === 'Escape') { handleDismiss(); setShowNewChatModal(false); setShowDialpad(false); } }; window.addEventListener('click', handleDismiss); window.addEventListener('blur', handleDismiss); window.addEventListener('keydown', handleKeyDown); return () => { window.removeEventListener('click', handleDismiss); window.removeEventListener('blur', handleDismiss); window.removeEventListener('keydown', handleKeyDown); }; }, []); // Hàm khi nhấn chuột phải vào tin nhắn const handleRightClick = (e, msg) => { e.preventDefault(); e.stopPropagation(); // Tính vị trí menu, đảm bảo không tràn màn hình const x = e.clientX; const y = e.clientY; setContextMenu({ x, y, msg }); }; // Hàm khi nhấn chuột phải vào danh sách bạn bè const handleListRightClick = (e, friend) => { e.preventDefault(); e.stopPropagation(); setListContextMenu({ x: e.clientX, y: e.clientY, friend }); }; const toggleMarkUnread = async () => { if (!listContextMenu || !user) return; const friend = listContextMenu.friend; const isCurrentlyUnread = friend.is_unread_db == 1 || unreadMessages[friend.userId] > 0; const newUnreadStatus = isCurrentlyUnread ? 0 : 1; try { await axios.post(`${API_BASE}?action=update_chat_setting`, { user_id: user.userId, friend_id: friend.userId, is_unread: newUnreadStatus }); if (newUnreadStatus === 1) { setUnreadMessages(prev => ({ ...prev, [friend.userId]: 1 })); friend.is_unread_db = 1; } else { setUnreadMessages(prev => { const newUnread = { ...prev }; delete newUnread[friend.userId]; return newUnread; }); friend.is_unread_db = 0; } } catch (e) { } setListContextMenu(null); }; const toggleArchive = async () => { if (!listContextMenu || !user) return; const friend = listContextMenu.friend; const newArchiveStatus = friend.is_archived_db == 1 ? 0 : 1; try { await axios.post(`${API_BASE}?action=update_chat_setting`, { user_id: user.userId, friend_id: friend.userId, is_archived: newArchiveStatus }); fetchRecentChats(); } catch (e) { } setListContextMenu(null); }; // Hàm ghim/bỏ ghim tin nhắn const togglePinMessage = () => { if (!contextMenu) return; const newPinned = { ...pinnedMessages }; const key = `${user.userId}_${activeChat.userId}_${contextMenu.msg.id}`; if (newPinned[key]) { delete newPinned[key]; } else { newPinned[key] = contextMenu.msg; } setPinnedMessages(newPinned); setContextMenu(null); }; // Hàm xóa tin nhắn 2 chiều (đã có trước đó) const handleDeleteMessageFromMenu = async () => { if (!contextMenu || !activeChat) return; if (contextMenu.msg.senderId !== user.userId) { showToast('Không thể xóa', 'Bạn chỉ có thể xóa tin nhắn của riêng mình!'); setContextMenu(null); return; } const msgIdToDelete = contextMenu.msg.id; setContextMenu(null); // Thêm ID vào danh sách đang xóa để chạy animation setDeletingMsgIds(prev => [...prev, msgIdToDelete]); // Đợi 300ms cho animation chạy xong rồi mới xóa hẳn khỏi state setTimeout(async () => { try { await axios.post(`${API_BASE}?action=delete_message`, { msgId: msgIdToDelete }); setMessages(prev => prev.filter(m => m.id !== msgIdToDelete)); setDeletingMsgIds(prev => prev.filter(id => id !== msgIdToDelete)); } catch (err) { showToast('Lỗi', 'Xóa tin nhắn thất bại!'); setDeletingMsgIds(prev => prev.filter(id => id !== msgIdToDelete)); } }, 300); }; useEffect(() => { userRef.current = user; if (user) { const savedSettings = localStorage.getItem(`chatSettings_${user.userId}`); if (savedSettings) setChatSettings(JSON.parse(savedSettings)); const savedFriends = localStorage.getItem(`friends_${user.userId}`); if (savedFriends) setFriends(JSON.parse(savedFriends)); const savedPinned = localStorage.getItem(`pinnedFriends_${user.userId}`); if (savedPinned) setPinnedFriends(JSON.parse(savedPinned)); } }, [user]); useEffect(() => { const savedUser = localStorage.getItem('chat_user'); if (savedUser) setUser(JSON.parse(savedUser)); const accounts = localStorage.getItem('lutos_saved_accounts'); if (accounts) setSavedAccounts(JSON.parse(accounts)); // Lắng nghe phím Escape để đóng modal xem ảnh const handleKeyDown = (e) => { if (e.key === 'Escape' && viewingImage) { setViewingImage(null); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [viewingImage]); const encryptPayload = async (text, fileUrl) => { const myPrivateKeyB64 = localStorage.getItem(`privKey_${user.userId}`); const myPublicKeyB64 = user.public_key; const receiverPublicKeyB64 = activeChat.public_key; if (!myPublicKeyB64 || !receiverPublicKeyB64) return null; try { const payloadStr = JSON.stringify({ text: text || '', fileUrl: fileUrl || '' }); const aesKey = await CryptoUtil.generateAESKey(); const { iv, ciphertext } = await CryptoUtil.encryptAES(payloadStr, aesKey); const myPubKey = await CryptoUtil.importPublicKey(myPublicKeyB64); const receiverPubKey = await CryptoUtil.importPublicKey(receiverPublicKeyB64); const encKeySender = await CryptoUtil.encryptKeyRSA(aesKey, myPubKey); const encKeyReceiver = await CryptoUtil.encryptKeyRSA(aesKey, receiverPubKey); return JSON.stringify({ E2EE: true, iv, ciphertext, encKeySender, encKeyReceiver }); } catch (e) { console.error("E2EE Encrypt Error", e); return null; } }; const fetchMessages = async () => { if (!activeChat || !user) return; try { const res = await axios.get(`${API_BASE}?action=get_messages&user1=${user.userId}&user2=${activeChat.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { const hasNewMessages = res.data.length > messages.length; const privKeyB64 = localStorage.getItem(`privKey_${user.userId}`); let privKey = null; if (privKeyB64) { try { privKey = await CryptoUtil.importPrivateKey(privKeyB64); } catch (e) { } } const decryptedList = await Promise.all(res.data.map(async (m) => { if (m.type === 'call_history') return m; try { if (m.content && m.content.startsWith('{') && m.content.includes('E2EE')) { const data = JSON.parse(m.content); if (data.E2EE) { if (!privKey) return { ...m, content: "🔒 Tin nhắn đã được mã hóa (Thiếu khóa riêng tư)", fileUrl: '' }; const encKeyToUse = (m.senderId === user.userId) ? data.encKeySender : data.encKeyReceiver; if (!encKeyToUse) return { ...m, content: "🔒 Tin nhắn không thể giải mã", fileUrl: '' }; try { const aesKey = await CryptoUtil.decryptKeyRSA(encKeyToUse, privKey); const plainText = await CryptoUtil.decryptAES(data.iv, data.ciphertext, aesKey); const payload = JSON.parse(plainText); return { ...m, content: payload.text, fileUrl: payload.fileUrl }; } catch (e) { return { ...m, content: "🔒 Tin nhắn đã được mã hóa (Sai thiết bị)", fileUrl: '' }; } } } } catch (e) { } return m; })); setMessages(prev => { if (prev.length !== decryptedList.length) return decryptedList; const prevIds = prev.map(m => m.id).join(''); const newIds = decryptedList.map(m => m.id).join(''); if (prevIds !== newIds) return decryptedList; return prev; }); // Đánh dấu đã đọc nếu đang xem chat này if (hasNewMessages) { setUnreadMessages(prev => { const newUnread = { ...prev }; delete newUnread[activeChat.userId]; return newUnread; }); } } } catch (e) { } }; const handleBlockUser = async (targetFriend) => { if (!user || !targetFriend) return; if (!confirm(`Bạn có chắc chắn muốn chặn ${targetFriend.name}? Bạn sẽ không nhận được tin nhắn từ người này và họ sẽ không thể tìm thấy bạn.`)) return; try { const res = await axios.post(`${API_BASE}?action=block_user`, { userId: user.userId, blockedId: targetFriend.userId }); if (res.data.success) { showToastAlert("Đã chặn", `Đã chặn ${targetFriend.name} thành công.`); fetchBlockedUsers(); setActiveChat(null); } } catch (e) { alert("Chặn người dùng thất bại, vui lòng thử lại."); } }; const handleUnblockUser = async (blockedUserId, blockedName) => { if (!user) return; try { const res = await axios.post(`${API_BASE}?action=unblock_user`, { userId: user.userId, blockedId: blockedUserId }); if (res.data.success) { showToastAlert("Đã bỏ chặn", `Đã bỏ chặn ${blockedName} thành công.`); fetchBlockedUsers(); } } catch (e) { alert("Bỏ chặn thất bại, vui lòng thử lại."); } }; const handleInstallApp = async () => { if (!deferredPrompt) { showToast('Cài đặt', 'Hộp thoại cài đặt chưa sẵn sàng hoặc ứng dụng đã được cài đặt!'); return; } deferredPrompt.prompt(); const { outcome } = await deferredPrompt.userChoice; if (outcome === 'accepted') { setDeferredPrompt(null); } }; const handleHiddenPinSubmit = (val) => { if (hiddenPinModalMode === 'setup') { if (val.length < 4) { setHiddenPinError('Mã PIN phải dài ít nhất 4 chữ số!'); return; } setHiddenPinTempSetup(val); setHiddenPinModalMode('confirm_setup'); setHiddenPinInputVal(''); setHiddenPinError(''); } else if (hiddenPinModalMode === 'confirm_setup') { if (val !== hiddenPinTempSetup) { setHiddenPinError('Mã PIN xác nhận không khớp! Hãy nhập lại.'); setHiddenPinInputVal(''); return; } localStorage.setItem(`hiddenChatsPin_${user.userId}`, val); setHiddenChatsPin(val); setHiddenPinInputVal(''); setHiddenPinError(''); if (hiddenPinTargetChat) { const updated = [...hiddenChats, hiddenPinTargetChat.userId]; localStorage.setItem(`hiddenChats_${user.userId}`, JSON.stringify(updated)); setHiddenChats(updated); setShowHiddenPinModal(false); setActiveChat(null); showToastAlert("Đã ẩn", `Đã ẩn cuộc trò chuyện với ${hiddenPinTargetChat.name}`); } else { setHiddenPinModalMode('enter_to_manage'); showToastAlert("Thành công", "Đã thiết lập mã PIN ẩn tin nhắn thành công!"); } } else if (hiddenPinModalMode === 'enter_to_hide') { if (val !== hiddenChatsPin) { setHiddenPinError('Mã PIN không đúng!'); setHiddenPinInputVal(''); return; } const updated = [...hiddenChats, hiddenPinTargetChat.userId]; localStorage.setItem(`hiddenChats_${user.userId}`, JSON.stringify(updated)); setHiddenChats(updated); setShowHiddenPinModal(false); setActiveChat(null); showToastAlert("Đã ẩn", `Đã ẩn cuộc trò chuyện với ${hiddenPinTargetChat.name}`); } else if (hiddenPinModalMode === 'enter_to_manage') { if (val !== hiddenChatsPin) { setHiddenPinError('Mã PIN không đúng!'); setHiddenPinInputVal(''); return; } setShowHiddenPinModal(false); setShowHiddenChatsList(true); } }; // Hàm khi chọn chat, đánh dấu đã đọc const handleSelectChat = async (friend) => { setActiveChat(friend); setContactSubView('profile'); setMediaActiveTab('media'); setShowMessageSearch(false); setMessageSearchQuery(''); setUnreadMessages(prev => { const newUnread = { ...prev }; delete newUnread[friend.userId]; return newUnread; }); if (friend.is_unread_db == 1) { try { await axios.post(`${API_BASE}?action=update_chat_setting`, { user_id: user.userId, friend_id: friend.userId, is_unread: 0 }); friend.is_unread_db = 0; } catch (e) { } } }; const handleClearMessages = async () => { if (!activeChat || !user) return; try { await axios.get(`${API_BASE}?action=delete_all_messages&user1=${user.userId}&user2=${activeChat.userId}&t=${Date.now()}`); setMessages([]); setShowContactInfo(false); showToast('Thành công', 'Đã xóa toàn bộ trò chuyện.'); } catch (e) { showToast('Lỗi', 'Có lỗi xảy ra khi xóa tin nhắn.'); } }; const handleBulkMarkAsRead = async () => { if (selectedChatIds.length === 0) return; try { for (const chatId of selectedChatIds) { setUnreadMessages(prev => { const newUnread = { ...prev }; delete newUnread[chatId]; return newUnread; }); await axios.post(`${API_BASE}?action=update_chat_setting`, { user_id: user.userId, friend_id: chatId, is_unread: 0 }); } setIsChatEditMode(false); setSelectedChatIds([]); showToast('Thành công', 'Đã đánh dấu đã đọc các mục đã chọn.'); fetchRecentChats(); } catch (e) { showToast('Lỗi', 'Không thể đánh dấu đã đọc.'); } }; const handleBulkDeleteChats = async () => { if (selectedChatIds.length === 0) return; const confirmDelete = window.confirm(`Bạn có chắc chắn muốn xóa ${selectedChatIds.length} cuộc trò chuyện đã chọn không?`); if (!confirmDelete) return; try { for (const chatId of selectedChatIds) { await axios.get(`${API_BASE}?action=delete_all_messages&user1=${user.userId}&user2=${chatId}&t=${Date.now()}`); } setIsChatEditMode(false); setSelectedChatIds([]); setActiveChat(null); showToast('Thành công', 'Đã xóa các trò chuyện đã chọn.'); fetchRecentChats(); } catch (e) { showToast('Lỗi', 'Có lỗi xảy ra khi xóa các trò chuyện.'); } }; const fetchGroups = async () => { if (!user) return; try { const res = await axios.get(`${API_BASE}?action=get_my_groups&userId=${user.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { const normalized = res.data.map(g => ({ ...g, userId: g.id || `group_${g.groupId}` })); setMyGroups(normalized); } } catch (e) { console.error("Fetch groups error:", e); } }; const fetchRecentChats = async () => { if (!user) return; try { const action = window.IS_ARCHIVE_MODE ? 'get_archived_chats' : 'get_recent_chats'; const res = await axios.get(`${API_BASE}?action=${action}&userId=${user.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { setFriends(prev => { const existingMap = new Map(prev.map(f => [f.userId, f])); const newFriends = res.data.map(chatUser => { existingMap.delete(chatUser.userId); return chatUser; }); const finalFriends = [...newFriends, ...Array.from(existingMap.values())]; if (JSON.stringify(prev) !== JSON.stringify(finalFriends)) { localStorage.setItem(`friends_${user.userId}`, JSON.stringify(finalFriends)); return finalFriends; } return prev; }); // Kiểm tra tin nhắn mới bằng so sánh timestamp O(1) để tránh làm chậm app và không bị kêu bíp bíp khi load lại trang res.data.forEach(friend => { const prevTime = lastMsgTimesRef.current[friend.userId]; const newTime = friend.last_msg_time; if (prevTime && prevTime !== newTime) { if (friend.is_unread_db == 1 && activeChat?.userId !== friend.userId) { playNotificationSound(); showToast(friend.name, "Bạn có tin nhắn mới!"); } } lastMsgTimesRef.current[friend.userId] = newTime; }); } } catch (e) { } }; const fetchCallHistory = async () => { if (!user) return; try { const res = await axios.get(`${API_BASE}?action=get_call_history&userId=${user.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { setCallHistory(res.data); } } catch (e) { } }; const fetchSignals = async () => { if (!user) return; try { const res = await axios.get(`${API_BASE}?action=get_signals&userId=${user.userId}&t=${Date.now()}`); if (res.data && Array.isArray(res.data)) { for (const sig of res.data) { const data = JSON.parse(sig.signal_data); if (sig.type === 'call') { setIncomingCall({ signal: data.signal, from: sig.from_user, name: data.name, isVideo: data.isVideo }); setCallState('incoming'); // Phát tiếng chuông reo khi có cuộc gọi đến try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = 800; oscillator.type = 'sine'; gainNode.gain.value = 0.3; // Tạo nhịp reo: reo 0.4s, nghỉ 0.2s let time = audioCtx.currentTime; for (let i = 0; i < 100; i++) { // Reo lâu hơn để đợi timeout oscillator.start(time); oscillator.stop(time + 0.4); time += 0.6; } ringtoneRef.current = { oscillator, gainNode, audioCtx }; } catch (e) { console.log('Ringtone error:', e); } // Tự động tắt sau 45s nếu không ai nghe if (ringTimeoutRef.current) clearTimeout(ringTimeoutRef.current); ringTimeoutRef.current = setTimeout(() => { endCallLocal(); }, 45000); } else if (sig.type === 'answer') { if (ringTimeoutRef.current) clearTimeout(ringTimeoutRef.current); if (peerConnection.current) { await peerConnection.current.setRemoteDescription(new window.RTCSessionDescription(data.signal)); for (const c of pendingCandidates.current) { await peerConnection.current.addIceCandidate(new window.RTCIceCandidate(c)); } pendingCandidates.current = []; } setCallState('connected'); callStartTime.current = Date.now(); setCallDuration(0); // Bắt đầu đếm thời gian if (callTimerRef.current) clearInterval(callTimerRef.current); callTimerRef.current = setInterval(() => { if (callStartTime.current) { const duration = Math.floor((Date.now() - callStartTime.current) / 1000); setCallDuration(duration); } }, 1000); } else if (sig.type === 'candidate') { if (peerConnection.current && peerConnection.current.remoteDescription) { await peerConnection.current.addIceCandidate(new window.RTCIceCandidate(data.candidate)); } else { pendingCandidates.current.push(data.candidate); } } else if (sig.type === 'end') { endCallLocal(); } } } } catch (e) { console.error('Signal fetch error:', e); } }; const checkUserStatus = async () => { if (!user) return; try { const res = await axios.get(`${API_BASE}?action=check_status&userId=${user.userId}&t=${Date.now()}`); if (res.data.status === 'deleted') { setNotFoundError(true); setUser(null); localStorage.removeItem('chat_user'); } else if (res.data.status === 'banned') { setBannedUser({ name: user.name, reason: res.data.ban_reason, time: res.data.ban_time }); setUser(null); localStorage.removeItem('chat_user'); } } catch (e) { } }; useEffect(() => { if (!user) return; fetchMessages(); fetchRecentChats(); fetchGroups(); fetchCallHistory(); checkUserStatus(); const interval = setInterval(() => { fetchMessages(); fetchRecentChats(); fetchGroups(); fetchCallHistory(); checkUserStatus(); }, 1500); // Ping trạng thái online mỗi 60s axios.post(`${API_BASE}?action=ping`, { userId: user.userId }); const pingInterval = setInterval(() => { axios.post(`${API_BASE}?action=ping`, { userId: user.userId }); }, 60000); return () => { clearInterval(interval); clearInterval(pingInterval); }; }, [user, activeChat]); // Effect để kiểm tra tín hiệu WebRTC (cuộc gọi) độc lập // Tăng tần suất lấy tín hiệu lên 500ms khi đang trong cuộc gọi để kết nối và cúp máy tức thì, không bị delay useEffect(() => { if (!user) return; let timer; const poll = async () => { await fetchSignals(); const currentInterval = callState ? 500 : 1500; timer = setTimeout(poll, currentInterval); }; const initialInterval = callState ? 500 : 1500; timer = setTimeout(poll, initialInterval); return () => { if (timer) clearTimeout(timer); }; }, [user, callState]); useEffect(() => { scrollRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); useEffect(() => { // Set local video stream if (localVideoRef.current && localStream) { localVideoRef.current.srcObject = localStream; } // Set remote video and audio stream if (remoteStream) { if (remoteVideoRef.current) { remoteVideoRef.current.srcObject = remoteStream; } if (remoteAudioRef.current) { remoteAudioRef.current.srcObject = remoteStream; // Try to play audio automatically remoteAudioRef.current.play().catch(e => { console.log('Auto play failed, user interaction needed:', e); // If auto-play fails, we'll let the audio element handle it when user interacts }); } } }, [localStream, remoteStream, callState]); const sendSignal = async (to, type, signalData) => { await axios.post(`${API_BASE}?action=send_signal`, { to_user: to, from_user: user.userId, type, signal_data: signalData }); }; const setupPeerConnection = (targetUserId, stream) => { const pc = new window.RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' }, { urls: 'stun:stun1.l.google.com:19302' } ] }); pc.onicecandidate = (e) => { console.log('ICE Candidate generated:', e.candidate ? 'Yes' : 'No'); if (e.candidate) { sendSignal(targetUserId, 'candidate', { candidate: e.candidate }); } }; pc.onconnectionstatechange = () => { console.log('Connection state:', pc.connectionState); if (pc.connectionState === 'disconnected' || pc.connectionState === 'failed' || pc.connectionState === 'closed') { endCallLocal(); } }; pc.ontrack = (e) => { console.log('Remote track received:', e.track.kind); if (e.streams && e.streams[0]) { // Only update stream if it's different setRemoteStream(prev => prev !== e.streams[0] ? e.streams[0] : prev); } }; if (stream) { console.log('Adding local tracks to peer connection:', stream.getTracks().length); stream.getTracks().forEach(track => { pc.addTrack(track, stream); }); } peerConnection.current = pc; return pc; }; const startCall = async (isVideo) => { if (!activeChat) return; // Kiểm tra HTTPS bảo mật cho cuộc gọi WebRTC if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { alert("Tính năng gọi thoại/video yêu cầu kết nối bảo mật HTTPS. Vui lòng chuyển sang địa chỉ trang web có dạng https:// để thực hiện cuộc gọi!"); return; } try { const stream = await navigator.mediaDevices.getUserMedia({ video: isVideo, audio: true }); setLocalStream(stream); setIsMicOn(true); setIsCameraOn(isVideo); setCallState('ringing'); if (ringTimeoutRef.current) clearTimeout(ringTimeoutRef.current); ringTimeoutRef.current = setTimeout(() => { sendSignal(activeChat.userId, 'end', {}); endCallLocal(); }, 45000); const pc = setupPeerConnection(activeChat.userId, stream); const offer = await pc.createOffer(); await pc.setLocalDescription(offer); sendSignal(activeChat.userId, 'call', { signal: offer, name: user.name, isVideo }); } catch (e) { alert("Không thể truy cập Camera/Microphone!"); } }; const answerCall = async () => { if (!incomingCall) return; // Kiểm tra HTTPS bảo mật cho cuộc gọi WebRTC if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') { alert("Tính năng gọi thoại/video yêu cầu kết nối bảo mật HTTPS. Vui lòng chuyển sang địa chỉ trang web có dạng https:// để nhận cuộc gọi!"); return; } try { // Dừng tiếng chuông reo của máy nhận cuộc gọi khi bấm nghe try { if (ringtoneRef.current) { ringtoneRef.current.oscillator?.stop(); ringtoneRef.current.audioCtx?.close(); ringtoneRef.current = null; } } catch (e) { } if (ringTimeoutRef.current) clearTimeout(ringTimeoutRef.current); const stream = await navigator.mediaDevices.getUserMedia({ video: incomingCall.isVideo, audio: true }); setLocalStream(stream); setIsMicOn(true); setIsCameraOn(incomingCall.isVideo); const pc = setupPeerConnection(incomingCall.from, stream); await pc.setRemoteDescription(new window.RTCSessionDescription(incomingCall.signal)); for (const c of pendingCandidates.current) { await pc.addIceCandidate(new window.RTCIceCandidate(c)); } pendingCandidates.current = []; const answer = await pc.createAnswer(); await pc.setLocalDescription(answer); sendSignal(incomingCall.from, 'answer', { signal: answer }); setCallState('connected'); callStartTime.current = Date.now(); setCallDuration(0); if (callTimerRef.current) clearInterval(callTimerRef.current); callTimerRef.current = setInterval(() => { if (callStartTime.current) { const duration = Math.floor((Date.now() - callStartTime.current) / 1000); setCallDuration(duration); } }, 1000); } catch (e) { alert("Không thể truy cập Camera/Microphone!"); } }; const endCallLocal = async () => { // Dừng tiếng chuông reo (nếu đang phát) try { if (ringtoneRef.current) { ringtoneRef.current.oscillator?.stop(); ringtoneRef.current.audioCtx?.close(); ringtoneRef.current = null; } } catch (e) { } // Dừng timer đếm thời gian if (callTimerRef.current) { clearInterval(callTimerRef.current); callTimerRef.current = null; } if (ringTimeoutRef.current) { clearTimeout(ringTimeoutRef.current); ringTimeoutRef.current = null; } // Lưu các trạng thái cần thiết để gửi tin nhắn lịch sử trước khi reset const savedCallState = callState; const savedIncomingCall = incomingCall; const savedCallStartTime = callStartTime.current; // Reset ngay lập tức các trạng thái cuộc gọi để giao diện tắt ngay không bị delay setCallState(null); setIncomingCall(null); setRemoteStream(null); setCallDuration(0); // Reset thời gian callStartTime.current = null; pendingCandidates.current = []; if (localStream) { localStream.getTracks().forEach(t => t.stop()); setLocalStream(null); } if (peerConnection.current) { peerConnection.current.close(); peerConnection.current = null; } // Gửi tin nhắn lịch sử cuộc gọi ở chế độ bất đồng bộ dưới nền, tránh làm chậm giao diện if (!savedIncomingCall && activeChat) { let msgText = ''; if (savedCallState === 'ringing') { msgText = 'Cuộc gọi nhỡ'; } else if (savedCallState === 'connected' && savedCallStartTime) { const duration = Math.floor((Date.now() - savedCallStartTime) / 1000); msgText = `Cuộc gọi thoại (${duration}s)`; } if (msgText) { axios.post(`${API_BASE}?action=send_message`, { senderId: user.userId, receiverId: activeChat.userId, content: msgText, type: 'call_history' }) .then(() => fetchMessages()) .catch(e => console.error("Error sending call history:", e)); } } }; // Hàm bật/tắt mic const toggleMic = () => { if (localStream) { localStream.getAudioTracks().forEach(track => track.enabled = !track.enabled); setIsMicOn(!isMicOn); } }; // Hàm bật/tắt camera const toggleCamera = () => { if (localStream) { localStream.getVideoTracks().forEach(track => track.enabled = !track.enabled); setIsCameraOn(!isCameraOn); } }; // Định dạng thời gian gọi (mm:ss) const formatCallTime = (seconds) => { const mins = Math.floor(seconds / 60).toString().padStart(2, '0'); const secs = (seconds % 60).toString().padStart(2, '0'); return `${mins}:${secs}`; }; const endCall = (targetId) => { sendSignal(targetId, 'end', {}); endCallLocal(); }; const handleRegister = async () => { setLoginError(''); setNotFoundError(false); if (!nameInput.trim()) return; if (!passwordInput.trim()) { setLoginError('Vui lòng nhập mật khẩu!'); return; } if (!isLoginMode && passwordInput.trim().length < 8) { setLoginError('Mật khẩu đăng ký phải có ít nhất 8 ký tự!'); return; } if (!isLoginMode && !acceptedTerms) { setLoginError('Bạn phải đọc và đồng ý với Điều khoản!'); return; } try { let loginId = nameInput.trim(); if (isLoginMode && /^\d{8}$/.test(loginId)) { loginId = loginId.slice(0, 3) + '-' + loginId.slice(3); } let publicKeyB64 = ''; let privateKeyB64 = ''; let existingPrivKey = isLoginMode ? localStorage.getItem(`privKey_${loginId}`) : null; let existingPubKey = isLoginMode ? localStorage.getItem(`pubKey_${loginId}`) : null; let recoveryCodeGenerated = null; let encryptedPrivKeyPayload = null; if (isLoginMode && existingPrivKey && existingPubKey) { publicKeyB64 = existingPubKey; } else { try { const keyPair = await CryptoUtil.generateKeyPair(); publicKeyB64 = await CryptoUtil.exportPublicKey(keyPair.publicKey); privateKeyB64 = await CryptoUtil.exportPrivateKey(keyPair.privateKey); } catch (e) { console.error("Key gen failed", e); } } let payload = isLoginMode ? { action: 'login', userId: loginId, password: passwordInput.trim() } : { action: 'register', name: nameInput.trim(), password: passwordInput.trim() }; if (publicKeyB64) payload.publicKey = publicKeyB64; const res = await axios.post(`login.php`, payload); if (isLoginMode && !existingPrivKey && res.data.encrypted_priv_key) { // Device changed, ask for recovery code setPendingLoginUser({ ...res.data, tmpPubKey: publicKeyB64, tmpPrivKey: privateKeyB64 }); setShowRecoveryInput(true); return; } if (res.data.userId && privateKeyB64) { localStorage.setItem(`privKey_${res.data.userId}`, privateKeyB64); localStorage.setItem(`pubKey_${res.data.userId}`, publicKeyB64); } setUser(res.data); localStorage.setItem('chat_user', JSON.stringify(res.data)); if (res.data.userId && res.data.name) { const currentAccounts = JSON.parse(localStorage.getItem('lutos_saved_accounts') || '[]'); const newAccount = { userId: res.data.userId, name: res.data.name }; const updatedAccounts = [newAccount, ...currentAccounts.filter(a => a.userId !== res.data.userId)].slice(0, 5); localStorage.setItem('lutos_saved_accounts', JSON.stringify(updatedAccounts)); setSavedAccounts(updatedAccounts); } } catch (err) { if (err.response?.data?.code === 'banned') { setBannedUser({ name: err.response.data.name, reason: err.response.data.ban_reason, time: err.response.data.ban_time }); } else if (err.response?.data?.code === 'not_found') { setNotFoundError(true); } else { setLoginError(err.response?.data?.error || 'Có lỗi xảy ra!'); } } }; const addFriend = async () => { if (!friendIdInput) return; let targetId = friendIdInput.trim(); if (/^\d{8}$/.test(targetId)) { targetId = targetId.slice(0, 3) + '-' + targetId.slice(3); } if (targetId === user.userId) { alert("Bạn không thể kết bạn với chính mình!"); return; } try { const res = await axios.get(`${API_BASE}?action=get_user&userId=${targetId}&requesterId=${user.userId}&t=${Date.now()}`); if (res.data && res.data.userId) { const savedFriends = JSON.parse(localStorage.getItem(`friends_${user.userId}`) || '[]'); if (!savedFriends.find(f => f.userId === res.data.userId)) { const updatedFriends = [...savedFriends, res.data]; setFriends(updatedFriends); localStorage.setItem(`friends_${user.userId}`, JSON.stringify(updatedFriends)); } alert(`Đã kết bạn với ${res.data.name} (${res.data.userId}) thành công!`); setActiveChat(res.data); setActiveTab('chats'); setFriendIdInput(''); } else { alert('Không tìm thấy người dùng'); } } catch (err) { alert('Không tìm thấy người dùng'); } }; // Hàm ghim/bỏ ghim bạn bè const togglePinFriend = (e, friendId) => { if (e) e.stopPropagation(); let newPinned; if (pinnedFriends.includes(friendId)) { newPinned = pinnedFriends.filter(id => id !== friendId); } else { newPinned = [...pinnedFriends, friendId]; } setPinnedFriends(newPinned); localStorage.setItem(`pinnedFriends_${user.userId}`, JSON.stringify(newPinned)); }; // Hàm phát tiếng thông báo tin nhắn mới const playNotificationSound = () => { try { const audioCtx = new (window.AudioContext || window.webkitAudioContext)(); const oscillator = audioCtx.createOscillator(); const gainNode = audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(audioCtx.destination); oscillator.frequency.value = 1000; oscillator.type = 'sine'; gainNode.gain.value = 0.2; oscillator.start(); oscillator.stop(audioCtx.currentTime + 0.15); } catch (e) { } }; const sendMessage = async () => { if (!messageInput.trim() || !activeChat) return; const content = messageInput; setMessageInput(''); let finalContent = content; const isGrpMsg = activeChat.is_group === true || activeChat.userId.startsWith('group_'); if (!isGrpMsg) { try { const encrypted = await encryptPayload(content, ''); if (encrypted) finalContent = encrypted; } catch (e) { } } try { await axios.post(`${API_BASE}?action=send_message`, { senderId: user.userId, receiverId: activeChat.userId, content: finalContent, type: 'text' }); fetchMessages(); } catch (err) { if (err.response && err.response.data && err.response.data.error === 'blocked') { const failedId = 'failed_' + Date.now(); const autoReplyId = 'autoreply_' + Date.now(); const failedMsg = { id: failedId, senderId: user.userId, receiverId: activeChat.userId, content: content, type: 'text', status: 'failed', timestamp: new Date().toISOString() }; const autoReplyMsg = { id: autoReplyId, senderId: activeChat.userId, receiverId: user.userId, content: "Bạn đã bị chặn bởi người dùng này. Tin nhắn không thể gửi đi.", type: 'system_block', timestamp: new Date().toISOString() }; setMessages(prev => [...prev, failedMsg, autoReplyMsg]); } else { alert("Gửi tin nhắn thất bại."); } } }; const handleUpdateProfile = async (e) => { const file = e.target.files?.[0]; if (file) { const formData = new FormData(); formData.append('image', file); try { const res = await axios.post(`${API_BASE}?action=upload`, formData); const newAvatarUrl = res.data.fileUrl; await axios.post(`${API_BASE}?action=update_profile`, { userId: user.userId, name: user.name, avatarUrl: newAvatarUrl }); const updatedUser = { ...user, avatarUrl: newAvatarUrl }; setUser(updatedUser); localStorage.setItem('chat_user', JSON.stringify(updatedUser)); } catch (err) { console.error('Upload avatar error', err); } } }; const handleEditAvatarChange = async (e) => { const file = e.target.files?.[0]; if (file) { const formData = new FormData(); formData.append('image', file); try { const res = await axios.post(`${API_BASE}?action=upload`, formData); setEditAvatarUrl(res.data.fileUrl); } catch (err) { alert('Không thể tải lên ảnh đại diện'); } } }; const handleSaveProfile = async () => { if (!editName.trim()) { alert('Vui lòng nhập tên hiển thị!'); return; } setIsSavingProfile(true); try { await axios.post(`${API_BASE}?action=update_profile`, { userId: user.userId, name: editName.trim(), avatarUrl: editAvatarUrl, bio: editBio.trim() }); const updatedUser = { ...user, name: editName.trim(), avatarUrl: editAvatarUrl, bio: editBio.trim() }; setUser(updatedUser); localStorage.setItem('chat_user', JSON.stringify(updatedUser)); setShowEditProfileModal(false); } catch (err) { alert('Không thể lưu thông tin hồ sơ!'); } finally { setIsSavingProfile(false); } }; const handleChangePassword = async () => { setChangePasswordError(''); if (!currentPassword) { setChangePasswordError('Vui lòng nhập mật khẩu hiện tại!'); return; } if (!newPassword) { setChangePasswordError('Vui lòng nhập mật khẩu mới!'); return; } if (newPassword.length < 8) { setChangePasswordError('Mật khẩu mới phải có ít nhất 8 ký tự!'); return; } if (newPassword !== confirmPassword) { setChangePasswordError('Mật khẩu nhập lại không khớp!'); return; } setIsChangingPassword(true); try { await axios.post(`${API_BASE}?action=change_password`, { userId: user.userId, currentPassword: currentPassword, newPassword: newPassword }); alert('Thay đổi mật khẩu tài khoản thành công!'); setShowChangePasswordModal(false); setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); } catch (err) { setChangePasswordError(err.response?.data?.error || 'Không thể thay đổi mật khẩu cũ hoặc kết nối lỗi!'); } finally { setIsChangingPassword(false); } }; const uploadAndSendMedia = async (file) => { if (!file) return; const formData = new FormData(); formData.append('image', file); try { const res = await axios.post(`${API_BASE}?action=upload`, formData); let type = 'image'; if (file.type.startsWith('video/')) type = 'video'; if (file.type.startsWith('audio/')) type = 'audio'; const fileUrl = res.data.fileUrl; let finalContent = ''; let finalFileUrl = fileUrl; try { const encrypted = await encryptPayload('', fileUrl); if (encrypted) { finalContent = encrypted; finalFileUrl = ''; } } catch (e) { } try { await axios.post(`${API_BASE}?action=send_message`, { senderId: user.userId, receiverId: activeChat.userId, content: finalContent, type, fileUrl: finalFileUrl }); fetchMessages(); } catch (err) { if (err.response && err.response.data && err.response.data.error === 'blocked') { const failedId = 'failed_' + Date.now(); const autoReplyId = 'autoreply_' + Date.now(); const failedMsg = { id: failedId, senderId: user.userId, receiverId: activeChat.userId, content: "[Tệp tin/Hình ảnh]", type: 'text', status: 'failed', timestamp: new Date().toISOString() }; const autoReplyMsg = { id: autoReplyId, senderId: activeChat.userId, receiverId: user.userId, content: "Bạn đã bị chặn bởi người dùng này. Tin nhắn không thể gửi đi.", type: 'system_block', timestamp: new Date().toISOString() }; setMessages(prev => [...prev, failedMsg, autoReplyMsg]); } else { alert("Gửi tin nhắn thất bại."); } } } catch (err) { console.error('Upload error', err); } }; const handleFileUpload = (e) => { uploadAndSendMedia(e.target.files[0]); e.target.value = null; }; const handlePaste = (e) => { if (e.clipboardData.files.length > 0) { const file = e.clipboardData.files[0]; if (file.type.startsWith('image/') || file.type.startsWith('video/')) { e.preventDefault(); uploadAndSendMedia(file); } } }; const startVoiceRecording = async () => { try { const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); const mediaRecorder = new window.MediaRecorder(stream); mediaRecorderRef.current = mediaRecorder; audioChunksRef.current = []; mediaRecorder.ondataavailable = e => audioChunksRef.current.push(e.data); mediaRecorder.onstop = () => { const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); const file = new File([audioBlob], "voice.webm", { type: 'audio/webm' }); uploadAndSendMedia(file); }; mediaRecorder.start(); setIsRecording(true); } catch (e) { alert("Không thể truy cập Microphone"); } }; const stopVoiceRecording = () => { mediaRecorderRef.current?.stop(); setIsRecording(false); }; const formatTime = (dateString) => { const d = new Date(dateString); return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); }; const getOnlineStatus = (lastActiveStr) => { if (!lastActiveStr) return "Ngoại tuyến"; const lastActive = new Date(lastActiveStr); const now = new Date(); const diffMs = now - lastActive; if (diffMs < 3 * 60 * 1000) return "Trực tuyến"; // < 3 minutes const diffMinutes = Math.floor(diffMs / 60000); if (diffMinutes < 60) return `Hoạt động ${diffMinutes} phút trước`; const diffHours = Math.floor(diffMinutes / 60); if (diffHours < 24) return `Hoạt động ${diffHours} giờ trước`; const diffDays = Math.floor(diffHours / 24); return `Hoạt động ${diffDays} ngày trước`; }; const getDisplayName = (friendId, defaultName) => chatSettings[friendId]?.nickname || defaultName; const renderMessageContent = (content, isMe) => { if (!content) return null; const urlRegex = /(https?:\/\/[^\s]+)/g; const parts = content.split(urlRegex); return parts.map((part, idx) => { if (part.match(urlRegex)) { return ( {part} ); } if (messageSearchQuery.trim()) { const query = messageSearchQuery.trim().toLowerCase(); const escapedQuery = query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const subParts = part.split(new RegExp(`(${escapedQuery})`, 'gi')); return ( {subParts.map((subPart, subIdx) => subPart.toLowerCase() === query ? {subPart} : subPart )} ); } return {part}; }); }; useEffect(() => { if (showRecoveryInput && recoveryInputVal.length === 6 && pendingLoginUser && !isVerifying && recoveryAttempts < 5) { const verify = async () => { setIsVerifying(true); setPinError(''); try { const aesK = await CryptoUtil.deriveAESKeyFromPassword(recoveryInputVal.trim()); const decPrivKeyStr = await CryptoUtil.decryptAES( JSON.parse(pendingLoginUser.encrypted_priv_key).iv, JSON.parse(pendingLoginUser.encrypted_priv_key).ciphertext, aesK ); localStorage.setItem(`privKey_${pendingLoginUser.userId}`, decPrivKeyStr); localStorage.setItem(`pubKey_${pendingLoginUser.userId}`, pendingLoginUser.tmpPubKey); setUser(pendingLoginUser); localStorage.setItem('chat_user', JSON.stringify(pendingLoginUser)); setShowRecoveryInput(false); setPendingLoginUser(null); setRecoveryInputVal(''); setRecoveryAttempts(0); } catch (e) { const nextAttempts = recoveryAttempts + 1; setRecoveryAttempts(nextAttempts); setRecoveryInputVal(''); // Xóa mã PIN cũ để gõ lại if (nextAttempts >= 5) { triggerPinError('Bạn đã nhập sai mã PIN quá 5 lần! Hệ thống tự động chuyển sang chế độ tạo khóa mới (mất tin nhắn cũ)...'); setTimeout(() => { handleSkipRecovery(); setRecoveryAttempts(0); }, 2500); } else { triggerPinError(`Mã PIN khôi phục không đúng! Bạn còn lại ${5 - nextAttempts} lần thử.`); } } finally { setIsVerifying(false); } }; const t = setTimeout(verify, 800); return () => clearTimeout(t); } }, [recoveryInputVal, showRecoveryInput, pendingLoginUser, recoveryAttempts, isVerifying]); const handleRecoverySubmit = async () => { if (recoveryInputVal.length !== 6 || isVerifying || recoveryAttempts >= 5) return; setIsVerifying(true); setPinError(''); try { const aesK = await CryptoUtil.deriveAESKeyFromPassword(recoveryInputVal.trim()); const decPrivKeyStr = await CryptoUtil.decryptAES( JSON.parse(pendingLoginUser.encrypted_priv_key).iv, JSON.parse(pendingLoginUser.encrypted_priv_key).ciphertext, aesK ); localStorage.setItem(`privKey_${pendingLoginUser.userId}`, decPrivKeyStr); localStorage.setItem(`pubKey_${pendingLoginUser.userId}`, pendingLoginUser.tmpPubKey); setUser(pendingLoginUser); localStorage.setItem('chat_user', JSON.stringify(pendingLoginUser)); setShowRecoveryInput(false); setPendingLoginUser(null); setRecoveryInputVal(''); setRecoveryAttempts(0); } catch (e) { const nextAttempts = recoveryAttempts + 1; setRecoveryAttempts(nextAttempts); setRecoveryInputVal(''); if (nextAttempts >= 5) { triggerPinError('Bạn đã nhập sai mã PIN quá 5 lần! Hệ thống tự động chuyển sang chế độ tạo khóa mới (mất tin nhắn cũ)...'); setTimeout(() => { handleSkipRecovery(); setRecoveryAttempts(0); }, 2500); } else { triggerPinError(`Mã PIN khôi phục không đúng! Bạn còn lại ${5 - nextAttempts} lần thử.`); } } finally { setIsVerifying(false); } }; const handleSkipRecovery = () => { localStorage.setItem(`privKey_${pendingLoginUser.userId}`, pendingLoginUser.tmpPrivKey); localStorage.setItem(`pubKey_${pendingLoginUser.userId}`, pendingLoginUser.tmpPubKey); axios.post(`login.php`, { action: 'login', userId: pendingLoginUser.userId, password: passwordInput.trim(), publicKey: pendingLoginUser.tmpPubKey }); setUser(pendingLoginUser); localStorage.setItem('chat_user', JSON.stringify(pendingLoginUser)); setShowRecoveryInput(false); setPendingLoginUser(null); setRecoveryAttempts(0); }; const handleViewAsUnverified = () => { localStorage.setItem(`pubKey_${pendingLoginUser.userId}`, pendingLoginUser.tmpPubKey); setUser(pendingLoginUser); localStorage.setItem('chat_user', JSON.stringify(pendingLoginUser)); setShowRecoveryInput(false); setPendingLoginUser(null); setRecoveryAttempts(0); }; const handleVerifyCurrentPin = async () => { if (pinSetupInputVal.length !== 6 || isVerifying || currentPinAttempts >= 5) return; setIsVerifying(true); setPinError(''); try { const aesK = await CryptoUtil.deriveAESKeyFromPassword(pinSetupInputVal.trim()); const decPrivKeyStr = await CryptoUtil.decryptAES( JSON.parse(user.encrypted_priv_key).iv, JSON.parse(user.encrypted_priv_key).ciphertext, aesK ); // Kiểm tra xem khóa private giải mã ra có trùng với khóa hiện tại trong localStorage không const localPrivKey = localStorage.getItem(`privKey_${user.userId}`); if (localPrivKey && decPrivKeyStr !== localPrivKey) { throw new Error("Incorrect PIN decryption"); } // Đúng PIN! Chuyển sang bước nhập PIN mới setPinSetupStep('enter_new'); setPinSetupInputVal(''); setPinError(''); setCurrentPinAttempts(0); } catch (e) { const nextAttempts = currentPinAttempts + 1; setCurrentPinAttempts(nextAttempts); setPinSetupInputVal(''); if (nextAttempts >= 5) { triggerPinError('Bạn đã nhập sai mã PIN hiện tại quá 5 lần! Thiết bị đã bị khóa và xóa toàn bộ tin nhắn cũ để bảo mật.'); // Xóa khóa bảo mật cục bộ localStorage.removeItem(`privKey_${user.userId}`); localStorage.removeItem(`pubKey_${user.userId}`); // Đăng xuất sau 2.5 giây setTimeout(() => { setShowPinSetupModal(false); setUser(null); localStorage.removeItem('chat_user'); }, 2500); } else { triggerPinError(`Mã PIN hiện tại không đúng! Bạn còn lại ${5 - nextAttempts} lần thử.`); } } finally { setIsVerifying(false); } }; const handleSavePinSetup = async (pinValue) => { const pinToSave = pinValue || pinSetupInputVal; if (!pinToSave || pinToSave.length !== 6 || !/^\d{6}$/.test(pinToSave)) { triggerPinError('Mã PIN khôi phục phải gồm đúng 6 chữ số!'); return; } setIsSavingPin(true); setPinError(''); try { const privateKeyB64 = localStorage.getItem(`privKey_${user.userId}`); if (!privateKeyB64) { triggerPinError('Không tìm thấy khóa bảo mật cục bộ của thiết bị!'); setIsSavingPin(false); return; } const aesK = await CryptoUtil.deriveAESKeyFromPassword(pinToSave); const encData = await CryptoUtil.encryptAES(privateKeyB64, aesK); const encryptedPrivKeyPayload = JSON.stringify(encData); await axios.post(`${API_BASE}?action=update_encrypted_priv_key`, { userId: user.userId, encryptedPrivKey: encryptedPrivKeyPayload }); // Cập nhật user state cục bộ const updatedUser = { ...user, encrypted_priv_key: encryptedPrivKeyPayload }; setUser(updatedUser); localStorage.setItem('chat_user', JSON.stringify(updatedUser)); // Hiển thị modal đăng ký thành công setShowRecoveryCode(pinToSave); setPinSetupInputVal(''); setShowPinSetupModal(false); // Đóng modal thủ công nếu thay đổi từ Settings } catch (e) { console.error("Encryption/Save PIN error", e); setPinError('Đã có lỗi xảy ra khi lưu mã PIN khôi phục!'); } finally { setIsSavingPin(false); } }; const handleSkipPinSetup = () => { if (showPinSetupModal) { setShowPinSetupModal(false); } else { setSkippedPinSetup(true); } setPinSetupInputVal(''); setPinError(''); }; if (bannedUser) { return (
{/* Header */}
lutos
Trợ giúp
{/* Safe Graphic Replicated in CSS */}
{/* Chain */}
{/* Safe box */}
{/* Dial */}
{/* Padlock */}
{/* Legs */}

{bannedUser.name} ơi, tài khoản của bạn đã bị khóa

Chúng tôi phát hiện hoạt động bất thường trên tài khoản của bạn. Như vậy nghĩa là ai đó đã sử dụng tài khoản này mà bạn không biết.


Ngày khóa tài khoản: {bannedUser.time ? new Date(bannedUser.time.replace(' ', 'T')).toLocaleString('vi-VN', { hour: '2-digit', minute: '2-digit', day: '2-digit', month: '2-digit', year: 'numeric' }) : 'Hôm nay'}

{bannedUser.reason && ( Lý do khóa: {bannedUser.reason} )} Để bảo vệ bạn, chúng tôi sẽ ẩn trang cá nhân của bạn với mọi người trên hệ thống và bạn cũng không thể sử dụng tài khoản của mình.

); } if (!user) { return (
{/* Background Image & Animations */}
{/* Subtle overlay to ensure the glassmorphic card stands out */}
{/* Animated Blobs */}
{/* Glassmorphic Card */}

{isLoginMode ? 'Đăng nhập' : 'Đăng ký'}

{isLoginMode ? 'Vui lòng nhập Số Lutos của bạn để đăng nhập' : 'Tạo một tài khoản Lutos ẩn danh mới'}

{ setNameInput(e.target.value); setLoginError(''); setNotFoundError(false); }} /> { setPasswordInput(e.target.value); setLoginError(''); setNotFoundError(false); }} onKeyPress={(e) => e.key === 'Enter' && handleRegister()} /> {loginError && (
{loginError}
)}

{notFoundError ? "Tài khoản không tồn tại hoặc đã bị xóa." : (isLoginMode ? "(!) Nếu bạn chưa thiết lập Mật khẩu cho Số Lutos của mình, hệ thống sẽ coi nó như một tài khoản không hợp lệ. Vui lòng quay lại để Đăng ký và ĐỪNG quên thiết lập mật khẩu." : "(!) Hãy thiết lập mật khẩu để bảo vệ Số Lutos của bạn. Nếu quên mật khẩu, bạn sẽ không thể khôi phục lại tài khoản.")}

{!isLoginMode && !notFoundError && (
setAcceptedTerms(e.target.checked)} className="mt-0.5 w-4.5 h-4.5 text-vn-red border-gray-300 rounded focus:ring-vn-red cursor-pointer shrink-0" />
)}
{isLoginMode && savedAccounts.length > 0 && (

Hoặc chọn tài khoản đã lưu:

{savedAccounts.map(acc => (
{ setNameInput(acc.userId || ''); setPasswordInput(''); }} className="flex items-center gap-3 p-3 rounded-2xl bg-white/60 hover:bg-vn-red/10 border border-gray-100 hover:border-vn-red/30 transition-all text-left w-full group shadow-sm hover:shadow-md cursor-pointer backdrop-blur-sm" >
{acc.name ? acc.name.charAt(0).toUpperCase() : '?'}
{acc.name || "Tài khoản lỗi"}
ID: {acc.userId || "N/A"}
))}
)}
{/* Modal nhập mã PIN khôi phục (Premium UI) */} {showRecoveryInput && (
{/* Ambient decorative glowing circles */}
{/* Glowing animated gradient top border */}
{/* Nút đóng X góc trên phải */} {/* High-tech Glowing Icon */}
{/* Tiêu đề & Phụ đề */}

Nhập mã PIN để khôi phục đoạn chat của bạn

Một số tin nhắn còn thiếu trên thiết bị mới. Hãy nhập mã PIN bảo mật để giải mã và khôi phục lịch sử trò chuyện của bạn.

{/* Remaining attempts indicator */}
{renderAttemptsBadge(recoveryAttempts, true)}
{/* Pill Asterisk Display with Blinking Cursor */}
{Array.from({ length: 6 }).map((_, idx) => { const hasChar = recoveryInputVal[idx] !== undefined; const isCurrent = recoveryInputVal.length === idx; return ( {hasChar ? ( ) : isCurrent ? ( _ ) : ( )} ); })}
{/* 6 Square boxes for PIN input */}
document.getElementById('pinRecoveryHiddenInput')?.focus()}> {Array.from({ length: 6 }).map((_, idx) => { const isFilled = recoveryInputVal[idx] !== undefined; const isCurrent = recoveryInputVal.length === idx; return (
{isFilled ? ( ) : ( )}
); })}
{/* Hidden Input field to control both Desktop and Mobile Keyboard */} { if (isVerifying) return; // Khóa gõ phím khi đang xác minh const val = e.target.value.replace(/\D/g, ''); if (val.length <= 6) { setRecoveryInputVal(val); setPinError(''); } }} autoFocus autoComplete="one-time-code" name="pin-recovery-input" /> {/* Trạng thái xác minh hoặc lỗi */} {isVerifying ? (
Đang xác minh mã PIN...
) : pinError ? (
{pinError}
) : (
// Giữ chỗ khoảng trống cho mượt )}
)}
); } const renderContactInfo = () => { if (!activeChat) return null; const isSelf = activeChat.userId === user.userId; if (contactSubView === 'media') { const sharedMedia = messages.filter(msg => msg.type === 'image' || msg.type === 'video'); // Lọc và trích xuất liên kết const urlRegex = /(https?:\/\/[^\s]+)/g; const sharedLinks = []; messages.forEach(msg => { if ((msg.type === 'text' || !msg.type) && msg.content) { const urls = msg.content.match(urlRegex); if (urls) { urls.forEach(url => { sharedLinks.push({ id: msg.id, url: url, senderId: msg.senderId, timestamp: msg.timestamp }); }); } } }); return ( ); } return ( ); }; const renderAdminDashboard = () => (
Quản lý Người dùng
{/* 1. Danh sách người dùng */}
1. Danh sách người dùng ({adminUsers.filter(u => u.is_admin != 1).length})
{loadingAdmin ? (
Đang tải...
) : adminUsers.filter(u => u.is_admin != 1).map(u => (
{u.avatarUrl ? : }
{u.name} {u.is_banned == 1 && BANNED}
ID: {u.userId}
))}
{/* 2. Chức năng */}
2. Chức năng (Ban / Mở khóa)
{loadingAdmin ? (
Đang tải...
) : adminUsers.filter(u => u.is_admin != 1).map(u => (
{u.avatarUrl ? : }
{u.name}
ID: {u.userId}
))}
{/* 3. Người dùng hiện bị ban */}
3. Người dùng hiện bị ban ({adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).length})
{adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).length === 0 ? (
Không có người dùng nào bị khóa
) : adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).map(u => (
{u.name}
ID: {u.userId}
))}
); return (
{/* RECOVERY CODE MODAL (Sau khi đăng ký / Thiết lập PIN thành công) */} {showRecoveryCode && (

Thiết lập thành công!

Mã PIN khôi phục tài khoản của bạn đã được thiết lập thành công. Vui lòng ghi nhớ mã PIN này để khôi phục tin nhắn khi đăng nhập trên thiết bị khác.

{showRecoveryCode.split('').map((char, idx) => (
{char}
))}
)} {/* PIN SETUP POPUP (Xuất hiện sau đăng ký/đăng nhập nếu chưa có PIN, hoặc khi thay đổi PIN từ Cài đặt) */} {user && (showPinSetupModal || ((!user.encrypted_priv_key || user.encrypted_priv_key === "") && !skippedPinSetup)) && (
{/* Ambient decorative glowing circles */}
{/* Glowing animated gradient top border */}
{/* Nút đóng X góc trên phải dành riêng cho chế độ Thay đổi PIN */} {showPinSetupModal && ( )} {/* Premium Glowing Header Icon */}

{pinSetupStep === 'verify_current' ? 'Xác minh mã PIN hiện tại' : (showPinSetupModal ? 'Thay đổi mã PIN khôi phục' : 'Thiết lập mã PIN bảo mật')}

{pinSetupStep === 'verify_current' ? 'Nhập mã PIN 6 số hiện tại của bạn để xác minh quyền thay đổi.' : (showPinSetupModal ? 'Nhập mã PIN 6 số mới của bạn. Mã PIN này dùng để mã hóa đầu cuối và khôi phục tin nhắn trên thiết bị khác.' : 'Mã hóa đầu cuối giúp tin nhắn của bạn tuyệt đối bí mật. Thiết lập mã PIN 6 số giúp khôi phục tin nhắn khi bạn đổi thiết bị. Nếu bỏ qua, bạn sẽ mất toàn bộ tin nhắn cũ khi đăng nhập thiết bị khác.' )}

{/* Remaining attempts indicator */} {pinSetupStep === 'verify_current' && (
{renderAttemptsBadge(currentPinAttempts, false)}
)} {pinSetupStep !== 'verify_current' &&
} {/* Pill Asterisk Lock Screen Display with Blinking Cursor */}
{Array.from({ length: 6 }).map((_, idx) => { const hasChar = pinSetupInputVal[idx] !== undefined; const isCurrent = pinSetupInputVal.length === idx; return ( {hasChar ? ( ) : isCurrent ? ( _ ) : ( )} ); })}
{/* 6 Square boxes for PIN input */}
document.getElementById('pinSetupHiddenInput')?.focus()}> {Array.from({ length: 6 }).map((_, idx) => { const isFilled = pinSetupInputVal[idx] !== undefined; const isCurrent = pinSetupInputVal.length === idx; return (
{isFilled ? ( ) : ( )}
); })}
{/* Hidden Input field to control both Desktop and Mobile Keyboard */} { if (isVerifying || isSavingPin) return; const val = e.target.value.replace(/\D/g, ''); if (val.length <= 6) { setPinSetupInputVal(val); setPinError(''); } }} autoFocus autoComplete="one-time-code" name="pin-setup-input" /> {pinError && (
{pinError}
)}
{!showPinSetupModal && ( )}
)} {/* CALL MODAL (FaceTime / Messenger Style) */} {(callState || incomingCall) && (
{/* Audio Element (luôn có để đảm bảo âm thanh phát ra) */}
)} {/* DESKTOP SIDE NAV */}
{user?.is_admin == 1 && ( )}
{/* MAIN LIST PANEL */}
{/* TAB: CHATS */} {activeTab === 'chats' && ( <>
{isChatEditMode ? ( /* Zangi Edit Mode Header */
{selectedChatIds.length} Được chọn
) : ( /* Standard Header */

{window.IS_ARCHIVE_MODE ? 'Tin nhắn lưu trữ' : 'Trò chuyện'}

)}
{ const val = e.target.value; setChatSearchQuery(val); if (hiddenChatsPin && val === hiddenChatsPin) { setChatSearchQuery(''); setShowHiddenChatsList(true); } }} className="w-full bg-white h-9 rounded-xl pl-9 pr-4 text-[15px] outline-none" />
{/* Personal Chat Row */} {(!chatSearchQuery.trim() || user.name.toLowerCase().includes(chatSearchQuery.toLowerCase())) && (
{ if (isChatEditMode) { setSelectedChatIds(prev => prev.includes(user.userId) ? prev.filter(id => id !== user.userId) : [...prev, user.userId] ); } else { setActiveChat(user); setShowMessageSearch(false); setMessageSearchQuery(''); } }} className={`flex items-center gap-3 py-3 cursor-pointer border-b border-gray-100 hover:bg-vn-paper transition-colors rounded-xl px-2 -mx-2 ${ activeChat?.userId === user.userId ? 'bg-vn-paper' : '' } ${isChatEditMode && selectedChatIds.includes(user.userId) ? 'bg-vn-paper/50' : ''}`} > {isChatEditMode && (
{selectedChatIds.includes(user.userId) && ( )}
)}
{user.name} (Tin nhắn cá nhân)
Gửi tin nhắn cho chính mình
)} {/* Sắp xếp bạn bè: ghim lên đầu, sau đó theo thời gian tin nhắn mới nhất */} {[...friends, ...myGroups].filter(f => !hiddenChats.includes(f.userId) && (!chatSearchQuery.trim() || f.name.toLowerCase().includes(chatSearchQuery.toLowerCase()))).sort((a, b) => { const aPinned = pinnedFriends.includes(a.userId); const bPinned = pinnedFriends.includes(b.userId); if (aPinned && !bPinned) return -1; if (!aPinned && bPinned) return 1; const aTime = a.last_msg_time ? new Date(a.last_msg_time).getTime() : 0; const bTime = b.last_msg_time ? new Date(b.last_msg_time).getTime() : 0; return bTime - aTime; }).map(friend => { const isGrp = friend.is_group === true || friend.userId.startsWith('group_'); return (
{ if (isChatEditMode) { setSelectedChatIds(prev => prev.includes(friend.userId) ? prev.filter(id => id !== friend.userId) : [...prev, friend.userId] ); } else { handleSelectChat(friend); } }} onContextMenu={(e) => handleListRightClick(e, friend)} className={`flex items-center gap-3 py-3 cursor-pointer border-b border-gray-100 last:border-none hover:bg-vn-paper transition-colors rounded-xl px-2 -mx-2 ${ activeChat?.userId === friend.userId ? 'bg-vn-paper' : '' } ${isChatEditMode && selectedChatIds.includes(friend.userId) ? 'bg-vn-paper/50' : ''}`} > {isChatEditMode && (
{selectedChatIds.includes(friend.userId) && ( )}
)}
{isGrp ? (
) : ( friend.avatarUrl ? : {friend.name[0]} )} {/* Badge xanh nếu có tin nhắn chưa đọc */} {(unreadMessages[friend.userId] > 0 || friend.is_unread_db == 1) && (
{unreadMessages[friend.userId] || ''}
)}
0 ? 'text-black font-extrabold' : 'text-vn-wood font-semibold'}`}> {isGrp ? friend.name : getDisplayName(friend.userId, friend.name)}
{pinnedFriends.includes(friend.userId) && ( )} Gần đây
{isGrp ? ( <> Nhóm • {friend.members ? `${friend.members.length} thành viên` : '3+ thành viên'} ) : ( <> Bấm để trò chuyện... )}
); })}
{/* Zangi Floating Bottom Bar */} {isChatEditMode && (
)} )} {/* TAB: CALLS */} {activeTab === 'calls' && ( <>
{callHistory.filter(call => callFilter === 'all' || call.content === 'Cuộc gọi nhỡ').length > 0 ? ( callHistory.filter(call => callFilter === 'all' || call.content === 'Cuộc gọi nhỡ').map(call => { const isOutgoing = call.senderId === user.userId; const otherName = isOutgoing ? call.receiverName : call.senderName; const otherAvatar = isOutgoing ? call.receiverAvatar : call.senderAvatar; const isMissed = call.content === 'Cuộc gọi nhỡ'; const iconColor = isMissed ? 'text-red-500' : (isOutgoing ? 'text-gray-500' : 'text-green-500'); return (
{otherAvatar ? : {otherName[0]}}
{otherName}
{call.content}
{formatTime(call.timestamp)}
); }) ) : (

Chưa có cuộc gọi nào

Tất cả cuộc gọi gần đây của bạn sẽ xuất hiện ở đây.

)}
)} {/* TAB: CONTACTS */} {activeTab === 'contacts' && ( <>

Danh bạ

setFriendIdInput(e.target.value)} onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); addFriend(); } }} className="w-full bg-white h-9 rounded-xl pl-9 pr-4 text-[15px] outline-none" />
{user.avatarUrl ? : user.name[0]}
{user.name}
{user.userId || user.id || ""}
setShowDialpad(true)} className="flex flex-col items-center gap-2 cursor-pointer">
Bàn phím
alert(`Link giới thiệu của bạn là:\nhttps://lutoschat.io.vn/ref.php?code=${user.ref_code}`)} className="flex flex-col items-center gap-2 cursor-pointer">
Mời
alert("Chưa có liên hệ yêu thích nào!")} className="flex flex-col items-center gap-2 cursor-pointer">
Yêu thích

Truy cập danh bạ thiết bị

Cho phép Lutos Chat sao chép liên hệ từ thiết bị của bạn vào đây. Hoặc bạn có thể thêm liên hệ Lutos Chat nội bộ bằng tay. Kích hoạt quyền truy cập

{/* DANH SÁCH LIÊN HỆ */}

Danh sách liên hệ ({friends.length})

{friends.length === 0 ? (
Chưa có liên hệ nào. Nhập ID và ấn nút (+) ở trên để kết bạn!
) : (
{friends .filter(f => !friendIdInput.trim() || f.name.toLowerCase().includes(friendIdInput.toLowerCase()) || f.userId.includes(friendIdInput)) .map(friend => (
{ setActiveChat(friend); setActiveTab('chats'); }} className="flex items-center gap-3 py-3 cursor-pointer hover:bg-gray-50 active:bg-gray-100 rounded-xl px-2 transition-colors" >
{friend.avatarUrl ? ( ) : ( {friend.name[0]} )}
{friend.name}
{friend.userId}
))}
)}
)} {/* TAB: SETTINGS */} {activeTab === 'settings' && (
{/* SUB-SCREEN: PROFILE */} {settingsSubScreen === 'profile' && (
{/* Header */}
Hồ sơ
{/* Profile Card */}
avatarInputRef.current?.click()} className="w-20 h-20 bg-gray-100 border-4 border-vn-paper rounded-full flex items-center justify-center text-vn-wood font-bold text-2xl overflow-hidden cursor-pointer shadow-md hover:scale-105 active:scale-95 transition-all relative group mb-3 z-10" > {user.avatarUrl ? : }
{user.name}
{user.userId || user.id || ""}
{/* Bio Row */}
Tiểu sử
{user.bio || "Thành viên của cộng đồng bảo mật Lutos Chat."}

Bạn có thể viết vài dòng về bản thân hoặc để trống.

{/* Change Password option */}
{ setCurrentPassword(''); setNewPassword(''); setConfirmPassword(''); setChangePasswordError(''); setShowChangePasswordModal(true); }} className="bg-white rounded-[24px] p-4 shadow-sm border border-gray-50 flex items-center justify-between cursor-pointer hover:bg-gray-50/50 transition-colors" >
Đổi mật khẩu

Bạn cần mật khẩu để đăng nhập lại. Hãy chắc chắn nhớ nó. Bạn sẽ không có lựa chọn để đặt lại mật khẩu sau khi đăng xuất.

{/* Log Out Button */} {/* Footnote */}

(!) Bạn có thể xóa tài khoản của mình từ Cài đặt → Quyền riêng tư → Xóa tài khoản. Hành động này không thể hoàn tác và tất cả dữ liệu sẽ bị mất.

)} {/* SUB-SCREEN: PRIVACY */} {settingsSubScreen === 'privacy' && (
{/* Header */}
Quyền riêng tư
{/* Spacer to center the title */}
{/* Switches Group */}
setOnlineStatus(!onlineStatus)} className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50/50 transition-colors rounded-t-[20px]" > Hiển thị trạng thái online của tôi
setTypingStatus(!typingStatus)} className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50/50 transition-colors" > Hiển thị khi tôi đang gõ
setSeenStatus(!seenStatus)} className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50/50 transition-colors rounded-b-[20px]" > Hiển thị trạng thái 'Đã xem'

Tắt tùy chọn này sẽ không cho phép bạn xem liệu người khác đã xem tin nhắn của bạn hay chưa.

{/* Blocked List */}
{ fetchBlockedUsers(); setShowBlockedUsersList(true); }} className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50/50 rounded-[20px] transition-colors" > Đã chặn

Liên hệ bị chặn sẽ không thể gọi cho bạn hoặc gửi tin nhắn cho bạn.

{/* Account Deletion */}
{ if (confirm("CẢNH BÁO CỰC KỲ QUAN TRỌNG: Bạn có chắc chắn muốn xóa vĩnh viễn tài khoản này không? Toàn bộ tin nhắn, hình ảnh, file gửi và lịch sử cuộc gọi sẽ bị xóa hoàn toàn khỏi hệ thống mà không thể khôi phục!")) { alert("Hệ thống bảo mật đang xử lý yêu cầu xóa tài khoản của bạn."); } }} className="flex items-center justify-between p-4 cursor-pointer hover:bg-red-50 rounded-[20px] transition-colors group" > Xóa Tài Khoản

Xóa Tài Khoản sẽ xóa tất cả dữ liệu của bạn vĩnh viễn, bao gồm tin nhắn, lịch sử cuộc gọi và thông tin hồ sơ.

{/* Pin and Hide Group */}
{ setHiddenPinTargetChat(null); if (hiddenChatsPin && hiddenChatsPin.length > 0) { setHiddenPinModalMode('enter_to_manage'); } else { setHiddenPinModalMode('setup'); } setHiddenPinInputVal(''); setHiddenPinError(''); setShowHiddenPinModal(true); }} className="flex items-center justify-between p-4 cursor-pointer hover:bg-gray-50/50 transition-colors" >
Quản lý cuộc trò chuyện ẩn
)} {/* SUB-SCREEN: DOWNLOAD APP */} {settingsSubScreen === 'download_app' && (
{/* Header */}
Tải ứng dụng
{/* Spacer */}
{/* PWA Mobile App Card */}

Ứng Dụng Điện Thoại

Cài đặt nhanh không cần qua App Store / Play Store

{isAppInstalled ? (
Bạn đang mở Lutos Chat từ ứng dụng đã được cài đặt!
) : (
{/* Check for iOS */} {/iPad|iPhone|iPod/.test(navigator.userAgent) && !window.MSStream ? (
Hướng dẫn cài đặt trên iOS (Safari):
  1. Nhấp vào nút Chia sẻ ở dưới cùng Safari.
  2. Cuộn xuống và chọn Thêm vào MH chính (Add to Home Screen).
  3. Nhấp vào nút Thêm (Add) ở góc trên cùng bên phải để hoàn tất.
) : deferredPrompt ? (

Lutos Chat hỗ trợ cài đặt trực tiếp lên màn hình chính điện thoại của bạn, giúp khởi động toàn màn hình nhanh chóng và tiết kiệm pin hơn.

) : (

Để cài đặt ứng dụng trên điện thoại, vui lòng nhấp vào nút menu của trình duyệt (icon 3 chấm ở Chrome) và chọn "Cài đặt ứng dụng" (Install App) hoặc "Thêm vào Màn hình chính".

)}
)}
{/* Desktop App Card */}

Ứng Dụng Máy Tính (Windows)

Chạy trực tiếp dạng phần mềm .EXE trên PC

Tải phiên bản Desktop chuyên nghiệp cho hệ điều hành Windows (10/11) để có trải nghiệm nhắn tin tối ưu, khởi động nhanh và hoạt động độc lập như một phần mềm độc lập.

TẢI BẢN WINDOWS (.EXE)
)} {/* MAIN SETTINGS SCREEN */} {!settingsSubScreen && (
Cài đặt
Số Lutos Chat của bạn
{user.userId || user.id || "Chưa có ID"}
setSettingsSubScreen('profile')}>
{user.avatarUrl ? : }
Hồ sơ của bạn
{user.name}
alert("Tính năng Lutos Chat Premium đang được phát triển!")} className="p-4 border-b border-gray-100 flex justify-between items-center cursor-pointer">
Lutos Chat Premium
Chưa đăng ký
Tham gia Lutos Chat Premium để gửi media với độ phân giải đầy đủ không giới hạn, có cuộc gọi lên đến 1000 người và mở khóa các tính năng giao tiếp bí mật.
alert(`Link giới thiệu của bạn là:\nhttps://lutoschat.io.vn/ref.php?code=${user.ref_code}`)} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer">
Mời bạn bè
setSettingsSubScreen('privacy')} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer">
Quyền riêng tư
{/* Dòng Xác thực thiết bị (Luôn hiện để báo trạng thái bảo mật) */} {isDeviceVerified ? (
alert("Thiết bị của bạn đã được xác thực thành công. Tất cả tin nhắn đang được mã hóa E2EE và bảo mật tuyệt đối!")} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer bg-green-50/20 hover:bg-green-50/40 transition-colors">
Trạng thái thiết bị Đã xác minh & Bảo mật E2EE
Đã bảo mật
) : (
{ setPendingLoginUser({ ...user, tmpPubKey: localStorage.getItem(`pubKey_${user.userId}`) }); setShowRecoveryInput(true); }} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer bg-red-50/50 hover:bg-red-50 transition-colors animate-pulse">
Xác thực thiết bị (PIN) Thiết bị của bạn chưa được xác minh
Chưa xác minh
)}
setSettingsSubScreen('download_app')} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer hover:bg-gray-50/50 transition-colors">
Tải ứng dụng di động & PC
{ setPinSetupInputVal(''); setPinError(''); if (user && user.encrypted_priv_key && user.encrypted_priv_key !== "") { setPinSetupStep('verify_current'); } else { setPinSetupStep('enter_new'); } setCurrentPinAttempts(0); setShowPinSetupModal(true); }} className="flex items-center justify-between p-4 border-b border-gray-100 cursor-pointer">
Thay đổi mã PIN khôi phục
setShowLogoutModal(true)} className="flex items-center justify-between p-4 cursor-pointer">
Đăng xuất
)}
)} {/* TAB: ADMIN (MENU) */} {activeTab === 'admin' && user?.is_admin == 1 && (
Quản trị Hệ thống
setAdminSubTab('list')} className={`p-4 rounded-2xl flex items-center gap-4 cursor-pointer transition-all border ${adminSubTab === 'list' ? 'bg-white border-vn-red shadow-sm text-vn-red' : 'bg-white/60 border-transparent hover:bg-white hover:shadow-sm text-gray-700'}`}>
Danh sách người dùng
setAdminSubTab('actions')} className={`p-4 rounded-2xl flex items-center gap-4 cursor-pointer transition-all border ${adminSubTab === 'actions' ? 'bg-white border-vn-red shadow-sm text-vn-red' : 'bg-white/60 border-transparent hover:bg-white hover:shadow-sm text-gray-700'}`}>
Chức năng (Khóa/Xóa)
setAdminSubTab('banned')} className={`p-4 rounded-2xl flex items-center gap-4 cursor-pointer transition-all border ${adminSubTab === 'banned' ? 'bg-white border-vn-red shadow-sm text-vn-red' : 'bg-white/60 border-transparent hover:bg-white hover:shadow-sm text-gray-700'}`}>
Người dùng bị khóa
{ if (confirm("CẢNH BÁO NGUY HIỂM:\nBạn có chắc chắn muốn XÓA SẠCH toàn bộ cơ sở dữ liệu? Tất cả tin nhắn, cuộc gọi và tài khoản người dùng khác sẽ bị xóa vĩnh viễn!")) { try { const res = await axios.post(`${API_BASE}?action=admin_wipe_database`, { requesterId: user.userId }); if (res.data.success) { alert("Dọn sạch toàn bộ cơ sở dữ liệu thành công! Ứng dụng sẽ tự động tải lại."); window.location.reload(); } } catch (e) { alert("Đã xảy ra lỗi khi xóa dữ liệu!"); } } }} className="p-4 rounded-2xl flex items-center gap-4 cursor-pointer transition-all border bg-red-50 border-red-200 hover:bg-red-100 text-red-700 mt-2">
Dọn sạch dữ liệu hệ thống
)}
{/* MOBILE BOTTOM NAV */} {!isChatEditMode && (
{user?.is_admin == 1 && ( )}
)} {/* CHAT AREA (Detail) */}
{activeTab === 'admin' && user?.is_admin == 1 && adminSubTab ? (
{adminSubTab === 'list' && 'Danh sách người dùng'} {adminSubTab === 'actions' && 'Chức năng (Khóa / Xóa)'} {adminSubTab === 'banned' && 'Người dùng hiện bị ban'}
{adminSubTab === 'list' && (
Danh sách ({adminUsers.filter(u => u.is_admin != 1).length})
{loadingAdmin ? (
Đang tải...
) : adminUsers.filter(u => u.is_admin != 1).map(u => (
{u.avatarUrl ? : }
{u.name} {u.is_banned == 1 && BANNED}
ID: {u.userId}
))}
)} {adminSubTab === 'actions' && (
Chức năng (Ban / Mở khóa)
{loadingAdmin ? (
Đang tải...
) : adminUsers.filter(u => u.is_admin != 1).map(u => (
{u.avatarUrl ? : }
{u.name}
ID: {u.userId}
))}
)} {adminSubTab === 'banned' && (
Người dùng hiện bị ban ({adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).length})
{adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).length === 0 ? (
Không có người dùng nào bị khóa
) : adminUsers.filter(u => u.is_admin != 1 && u.is_banned == 1).map(u => (
{u.name}
ID: {u.userId}
))}
)}
) : activeTab === 'admin' && !isMobile ? (

Bảng điều khiển

Vui lòng chọn một chức năng quản trị ở danh sách bên trái.

) : !activeChat && !isMobile ? (

Chọn một cuộc trò chuyện

) : activeChat && (
{/* Header */}
setShowContactInfo(!showContactInfo)}>
{activeChat.avatarUrl ? : {activeChat.name[0]}}
{activeChat.userId === user.userId ? `${getDisplayName(activeChat.userId, activeChat.name)} (Cá nhân)` : getDisplayName(activeChat.userId, activeChat.name)} {activeChat.userId === user.userId ? "Gửi cho chính mình" : getOnlineStatus(activeChat.last_active)}
{activeChat.userId !== user.userId && ( <> )}
{/* Message Search Bar */} {showMessageSearch && (
setMessageSearchQuery(e.target.value)} placeholder="Tìm từ khóa trong trò chuyện..." className="w-full bg-white h-9 rounded-xl pl-9 pr-8 text-[14px] outline-none border border-gray-200 focus:border-vn-red focus:ring-2 focus:ring-vn-red/10 transition-all shadow-sm" autoFocus /> {messageSearchQuery && ( )}
)} {/* Messages */}
{!isDeviceVerified ? ( /* Center lock card if unverified */

Thiết bị chưa xác minh

Bạn đang đăng nhập trên thiết bị mới. Vui lòng nhập mã PIN để giải mã lịch sử trò chuyện cũ và tiếp tục gửi/nhận tin nhắn mới.

) : ( /* Standard message stream */
{activeChat.userId === user.userId && (

Đây là không gian cá nhân của bạn. Nhắn tin cho chính mình, gửi công việc, tệp tin và liên kết. Mọi thứ được lưu trữ cục bộ trên thiết bị của bạn.

)} {(messageSearchQuery.trim() ? messages.filter(m => m.content && m.content.toLowerCase().includes(messageSearchQuery.trim().toLowerCase())) : messages ).map(msg => { const isMe = msg.senderId === user.userId; // Hiển thị tin nhắn hệ thống (Lịch sử cuộc gọi) if (msg.type === 'call_history') { const isMissed = msg.content === 'Cuộc gọi nhỡ'; return (
{msg.content}
); } // Hiển thị tin nhắn bị chặn (system_block) if (msg.type === 'system_block') { return (
⚠️ {msg.content}
); } const isGrp = activeChat.is_group === true || activeChat.userId.startsWith('group_'); let senderName = ''; if (isGrp && !isMe) { const member = activeChat.members?.find(m => m.userId === msg.senderId); senderName = member ? member.name : msg.senderId; } return (
handleRightClick(e, msg)}> {/* Nút ghim bên trái nếu là tin của người khác */} {!isMe && (
)}
{senderName && (
{senderName}
)} {msg.type === 'image' && Ảnh setViewingImage(msg.fileUrl)} />} {msg.type === 'video' &&
{/* Nút ghim bên phải nếu là tin của mình */} {isMe && (
)}
); })}
)}
{/* Input */}
{!isDeviceVerified ? ( /* Locked Input Area */
{ setPendingLoginUser({ ...user, tmpPubKey: localStorage.getItem(`pubKey_${user.userId}`) }); setShowRecoveryInput(true); }} className="max-w-4xl mx-auto w-full bg-white/80 backdrop-blur-sm border border-red-200/50 rounded-2xl p-4 flex items-center justify-between cursor-pointer hover:bg-white transition-all shadow-sm group active:scale-[0.99]" >
Trò chuyện đang bị khóa Vui lòng nhập mã PIN bảo mật để gửi/nhận tin nhắn.
) : ( /* Standard Input Area */ <>
{['👍', '❤️', '😂', '🔥', '🎉', '💩', '😢', '😍', '👏', '🤝', '🇻🇳'].map(emoji => ( ))}
setMessageInput(e.target.value)} onKeyPress={(e) => e.key === 'Enter' && sendMessage()} onPaste={handlePaste} />
{messageInput.trim() ? ( ) : ( )}
)}
{showContactInfo && renderContactInfo()}
)}
{/* CONTEXT MENU (Menu chuột phải) */} {contextMenu && (
{/* Nút xem trước */} {(contextMenu.msg.type === 'image' || contextMenu.msg.type === 'video') && ( )} {/* Nút ghim tin nhắn */} {/* Nút xóa tin nhắn 2 chiều (chỉ hiện nếu là tin của mình) */} {contextMenu.msg.senderId === user.userId && ( )}
)} {/* CONTEXT MENU DANH SÁCH (Menu chuột phải trên PC) */} {listContextMenu && (
)} {/* MODAL XEM ẢNH FULLSCREEN */} {viewingImage && (
setViewingImage(null)} onKeyDown={(e) => e.key === 'Escape' && setViewingImage(null)} > Xem ảnh e.stopPropagation()} />
)} {/* DIALPAD MODAL */} {showDialpad && (

Nhập số Lutos

{dialNumber || '000-00000'}

{[ { num: '1', letters: '' }, { num: '2', letters: 'ABC' }, { num: '3', letters: 'DEF' }, { num: '4', letters: 'GHI' }, { num: '5', letters: 'JKL' }, { num: '6', letters: 'MNO' }, { num: '7', letters: 'PQRS' }, { num: '8', letters: 'TUV' }, { num: '9', letters: 'WXYZ' }, { num: '*', letters: '' }, { num: '0', letters: '+' }, { num: '#', letters: '' } ].map(key => ( ))}
{dialNumber && ( )}
)} {/* NEW CHAT MODAL (NÚT +) */} {showNewChatModal && (
setShowNewChatModal(false)}>
e.stopPropagation()}>

Trò chuyện mới

!

Truy cập danh bạ thiết bị

Cho phép Lutos sao chép liên hệ từ thiết bị của bạn vào đây. Hoặc bạn có thể thêm liên hệ Lutos nội bộ bằng tay. Kích hoạt quyền truy cập

)} {/* CREATE GROUP MODAL */} {showCreateGroupModal && (
setShowCreateGroupModal(false)}>
e.stopPropagation()}> {/* Header */}

Tạo Nhóm Mới

{/* Body */}
{/* Group Name input */}
setGroupNameInput(e.target.value)} />
{/* Member Selection list */}
{friends.length === 0 ? (
Bạn chưa kết nối với bạn bè nào để tạo nhóm.
) : (
{friends.map(friend => { const isSelected = selectedGroupMembers.includes(friend.userId); return (
{ setSelectedGroupMembers(prev => isSelected ? prev.filter(id => id !== friend.userId) : [...prev, friend.userId] ); }} className="flex items-center justify-between py-2.5 cursor-pointer hover:bg-gray-50 px-1 rounded-lg transition-colors" >
{friend.avatarUrl ? : {friend.name[0]}}
{friend.name}
{friend.userId}
{isSelected && }
); })}
)}
{/* Requirements & Submit button */}
{selectedGroupMembers.length < 2 ? ( ⚠️ Bạn cần chọn thêm ít nhất {2 - selectedGroupMembers.length} thành viên để tạo nhóm (Nhóm yêu cầu ít nhất 3 người). ) : ( 🎉 Đủ điều kiện! Tổng số thành viên: {selectedGroupMembers.length + 1} người (bao gồm bạn). )}
)} {/* EDIT PROFILE MODAL */} {showEditProfileModal && (

Chỉnh sửa hồ sơ

{/* Avatar Selector */}
editAvatarInputRef.current?.click()} className="w-24 h-24 bg-gray-100 border-4 border-vn-paper rounded-full flex items-center justify-center text-vn-wood font-bold text-2xl overflow-hidden cursor-pointer shadow-md hover:scale-105 active:scale-95 transition-all relative group" > {editAvatarUrl ? : }
{/* Display Name Input */}
setEditName(e.target.value)} placeholder="Tên của bạn..." className="w-full bg-white/60 focus:bg-white text-vn-wood px-4 py-3 rounded-2xl outline-none border border-gray-200 focus:border-vn-red focus:ring-4 focus:ring-vn-red/10 transition-all text-[15px] shadow-sm" />
{/* Bio/Biography Input */}