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.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 */}
{/* 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.
setBannedUser(null)} className="w-full bg-[#1877F2] text-white font-bold text-[17px] py-2.5 rounded-lg shadow-sm hover:bg-[#166FE5] transition">
Bắt đầu
);
}
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 && (
)}
{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"}
{
e.stopPropagation();
const updated = savedAccounts.filter(a => a.userId !== acc.userId);
setSavedAccounts(updated);
localStorage.setItem('lutos_saved_accounts', JSON.stringify(updated));
}}
className="w-8 h-8 rounded-full hover:bg-red-100 text-gray-400 hover:text-red-500 flex items-center justify-center transition-colors shrink-0"
title="Xóa tài khoản đã lưu"
>
))}
)}
Tiếp tục
{ setIsLoginMode(!isLoginMode); setNameInput(''); setPasswordInput(''); setLoginError(''); setNotFoundError(false); }} className="text-[#7D6B5D] text-[14.5px] font-medium hover:text-vn-red hover:underline transition-colors">
{isLoginMode ? 'Chưa có tài khoản? Đăng ký mới' : 'Đã có tài khoản? Đăng nhập'}
{/* 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 */}
{
if (pendingLoginUser) {
handleViewAsUnverified();
} else {
setShowRecoveryInput(false);
}
}}
className="absolute top-4 right-4 w-9 h-9 rounded-full bg-vn-paper hover:bg-[#E8E2D2] flex items-center justify-center text-[#7D6B5D] hover:text-vn-wood transition-all duration-200 z-20 shadow-sm"
>
{/* 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 ? (
●
) : (
•
)}
);
})}
Dán
{/* 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 ? (
) : pinError ? (
{pinError}
) : (
// Giữ chỗ khoảng trống cho mượt
)}
{
if (pendingLoginUser) {
handleViewAsUnverified();
} else {
setShowRecoveryInput(false);
}
}}
className="w-full bg-[#1A120D] text-white hover:bg-black py-3 rounded-2xl font-bold active:scale-[0.98] transition-all duration-200 z-10 text-[13.5px]"
>
Xem dưới dạng chưa xác minh
{
if (confirm("CẢNH BÁO: Tạo khóa mới sẽ XÓA VĨNH VIỄN toàn bộ tin nhắn cũ trên hệ thống vì không có mã PIN để giải mã. Bạn có chắc chắn muốn tiếp tục không?")) {
handleSkipRecovery();
}
}}
className="w-full bg-red-50 hover:bg-red-100 text-red-600 py-2 rounded-2xl font-medium active:scale-[0.98] transition-all duration-200 z-10 text-[12px] mt-3 border border-red-200/40"
>
Quên PIN? Bỏ qua để tạo khoá mới (Mất tin nhắn cũ)
)}
);
}
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 (
setContactSubView('profile')} className="text-vn-red font-medium text-[17px] flex items-center">
Phương tiện & Liên kết
{/* Tabs Selector */}
setMediaActiveTab('media')}
className={`flex-1 text-center py-3 text-[14px] font-bold transition-all border-b-2 ${mediaActiveTab === 'media' ? 'border-vn-red text-vn-red' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
Ảnh và Video
setMediaActiveTab('links')}
className={`flex-1 text-center py-3 text-[14px] font-bold transition-all border-b-2 ${mediaActiveTab === 'links' ? 'border-vn-red text-vn-red' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
>
Liên kết
{mediaActiveTab === 'media' ? (
sharedMedia.length === 0 ? (
Chưa có phương tiện nào được chia sẻ
) : (
{sharedMedia.map(msg => (
{
if (msg.type === 'image') setViewingImage(msg.fileUrl);
}}
>
{msg.type === 'image' ? (
) : (
)}
))}
)
) : (
sharedLinks.length === 0 ? (
Chưa có liên kết nào được chia sẻ
) : (
)
)}
);
}
return (
setShowContactInfo(false)} className="text-vn-red font-medium text-[17px] flex items-center">
Thông tin liên hệ
{activeChat.avatarUrl ?
:
{activeChat.name[0]} }
{isSelf ? `${activeChat.name} (Tin nhắn cá nhân)` : getDisplayName(activeChat.userId, activeChat.name)}
{isSelf ? "Gửi tin nhắn cho chính mình" : "Trực tuyến"}
setShowContactInfo(false)}>
Chat
{!isSelf && (
<>
{ setShowContactInfo(false); startCall(false); }}>
Gọi
{ setShowContactInfo(false); startCall(true); }}>
Video
>
)}
Số Lutos
{activeChat.userId}
{ navigator.clipboard.writeText(activeChat.userId); }} className="bg-vn-paper hover:bg-[#E8E2D2] text-vn-wood px-3 py-1.5 rounded-full text-[12px] sm:text-[13px] font-medium transition-colors shrink-0">Sao chép
alert('Đang phát triển')}>
Tạo Nhóm
setContactSubView('media')}>
Phương tiện đã chia sẻ
{
setShowContactInfo(false);
setHiddenPinTargetChat(activeChat);
if (hiddenChatsPin && hiddenChatsPin.length > 0) {
setHiddenPinModalMode('enter_to_hide');
} else {
setHiddenPinModalMode('setup');
}
setHiddenPinInputVal('');
setHiddenPinError('');
setShowHiddenPinModal(true);
}}
>
Ẩn cuộc trò chuyện
Xóa trò chuyện
{!isSelf && (
<>
alert('Đã báo cáo.')}>
Báo cáo
handleBlockUser(activeChat)}>
Chặn liên hệ này
>
)}
);
};
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})
Làm mới
{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 ?
:
}
handleBanUser(u.userId, u.is_banned)} className={`px-3 py-1.5 rounded-full text-[13px] font-medium flex items-center gap-1.5 transition-colors ${u.is_banned == 1 ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}>
{u.is_banned == 1 ? 'Mở khóa' : 'Khóa'}
handleDeleteUser(u.userId)} className="px-3 py-1.5 rounded-full text-[13px] font-medium flex items-center gap-1.5 transition-colors bg-red-100 text-red-700 hover:bg-red-200">
Xóa
))}
{/* 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 => (
handleBanUser(u.userId, 1)} className="px-3 py-1.5 rounded-full text-[13px] font-medium flex items-center gap-1.5 transition-colors bg-green-100 text-green-700 hover:bg-green-200">
Mở khóa
))}
);
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}
))}
setShowRecoveryCode(null)} className="w-full bg-vn-red text-white py-3 rounded-xl font-bold hover:bg-red-700 transition active:scale-[0.98]">
Tôi đã ghi nhớ mã PIN an toàn
)}
{/* 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 ? (
●
) : (
•
)}
);
})}
Dán
{/* 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}
)}
{
if (pinSetupStep === 'verify_current') {
handleVerifyCurrentPin();
} else {
handleSavePinSetup();
}
}}
disabled={pinSetupInputVal.length !== 6 || isSavingPin || isVerifying}
className={`w-full py-3.5 rounded-2xl font-bold transition-all duration-300 flex items-center justify-center gap-2 ${
pinSetupInputVal.length === 6 && !isSavingPin && !isVerifying
? 'bg-gradient-to-r from-vn-red via-vn-red-light to-orange-500 text-white shadow-lg hover:shadow-vn-red/30 hover:-translate-y-0.5 active:translate-y-0 active:scale-[0.98]'
: 'bg-gray-100 text-gray-400 cursor-not-allowed'
}`}
>
{pinSetupStep === 'verify_current' ? (
isVerifying ? (
<>
Đang xác minh...
>
) : (
'Xác thực mã PIN cũ'
)
) : (
isSavingPin ? (
<>
Đang lưu mã PIN...
>
) : (
showPinSetupModal ? 'Cập nhật mã PIN mới' : 'Thiết lập bảo mật'
)
)}
{!showPinSetupModal && (
Bỏ qua (Chấp nhận mất tin nhắn cũ)
)}
)}
{/* CALL MODAL (FaceTime / Messenger Style) */}
{(callState || incomingCall) && (
{/* Audio Element (luôn có để đảm bảo âm thanh phát ra) */}
{/* TOP BAR: Status & Encryption */}
Mã hóa bảo mật
{callState === 'connected' && (
ĐANG GỌI • {formatCallTime(callDuration)}
)}
{/* MAIN CALL VIEWPORT */}
{callState === 'connected' ? (
<>
{/* Video của đối phương (Lớn, tràn màn hình) */}
{/* Avatar lớn của đối phương nếu camera tắt */}
{!isCameraOn && (
{/* Hộp trừu tượng phát sáng phía sau avatar */}
{(activeChat?.name || incomingCall?.name || 'U')[0].toUpperCase()}
Camera đối phương đang tắt
)}
{/* Video của bạn (Nhỏ, nổi ở góc trên phải) */}
{!isCameraOn && (
Tắt Cam
)}
>
) : (
/* MÀN HÌNH CHỜ CUỘC GỌI / ĐANG ĐỔ CHUÔNG */
{/* Blur backdrop glowing orb */}
{callState === 'incoming' ? incomingCall?.name[0].toUpperCase() : activeChat?.name[0].toUpperCase()}
{/* Sóng âm FaceTime phát sáng */}
{callState === 'ringing' && `Đang gọi ${activeChat?.name}`}
{callState === 'incoming' && incomingCall?.name}
{callState === 'ringing' && (
<>
Đang chờ phản hồi...
>
)}
{callState === 'incoming' && (
<>
Cuộc gọi thoại đến
>
)}
)}
{/* BOTTOM FLOATING CONTROLS (Facetime Glassmorphic Style) */}
{/* Control Bar for Connected State */}
{callState === 'connected' ? (
{isMicOn ? : }
{isCameraOn ? : }
endCall(incomingCall ? incomingCall.from : activeChat?.userId)}
className="w-12 h-12 sm:w-14 sm:h-14 bg-red-600 hover:bg-red-700 text-white rounded-full flex items-center justify-center shadow-lg transition-all hover:scale-110 active:scale-95 border border-red-500/30"
title="Gác máy"
>
) : (
/* Action Buttons for Incoming / Outgoing Call States */
{callState === 'incoming' ? (
<>
{/* Nút từ chối cuộc gọi đến */}
endCall(incomingCall.from)}
className="w-16 h-16 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white shadow-xl rotate-[135deg] transition-all hover:scale-110 active:scale-95 border border-red-500/20"
title="Từ chối"
>
{/* Nút nghe cuộc gọi đến */}
>
) : (
/* Nút hủy cuộc gọi đi */
endCall(activeChat?.userId)}
className="w-16 h-16 bg-red-600 hover:bg-red-700 rounded-full flex items-center justify-center text-white shadow-xl rotate-[135deg] transition-all hover:scale-110 active:scale-95 border border-red-500/20"
title="Hủy cuộc gọi"
>
)}
)}
)}
{/* DESKTOP SIDE NAV */}
{ setActiveTab('chats'); setActiveChat(null); }} className={`p-3 rounded-2xl transition-all ${activeTab === 'chats' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D] hover:bg-[#E8E2D2]'}`}>
{ setActiveTab('calls'); setActiveChat(null); }} className={`p-3 rounded-2xl transition-all ${activeTab === 'calls' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D] hover:bg-[#E8E2D2]'}`}>
{ setActiveTab('contacts'); setActiveChat(null); }} className={`p-3 rounded-2xl transition-all ${activeTab === 'contacts' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D] hover:bg-[#E8E2D2]'}`}>
{ setActiveTab('settings'); setActiveChat(null); }} className={`p-3 rounded-2xl transition-all ${activeTab === 'settings' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D] hover:bg-[#E8E2D2]'}`}>
{user?.is_admin == 1 && (
{ setActiveTab('admin'); setActiveChat(null); setAdminSubTab(null); }} className={`p-3 rounded-2xl transition-all ${activeTab === 'admin' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D] hover:bg-[#E8E2D2]'}`} title="Quản trị">
)}
{/* MAIN LIST PANEL */}
{/* TAB: CHATS */}
{activeTab === 'chats' && (
<>
{isChatEditMode ? (
/* Zangi Edit Mode Header */
{
setIsChatEditMode(false);
setSelectedChatIds([]);
}}
className="px-4 py-1.5 rounded-full bg-white text-vn-wood hover:bg-vn-wood hover:text-white font-bold text-[13.5px] shadow-sm transition-all active:scale-95 border border-gray-100"
>
Xong
{selectedChatIds.length} Được chọn
{
const allChatIds = [user.userId, ...friends.map(f => f.userId), ...myGroups.map(g => g.id)];
if (selectedChatIds.length === allChatIds.length) {
setSelectedChatIds([]);
} else {
setSelectedChatIds(allChatIds);
}
}}
className="px-4 py-1.5 rounded-full bg-white text-vn-wood hover:bg-vn-wood hover:text-white font-bold text-[13.5px] shadow-sm transition-all active:scale-95 border border-gray-100"
>
{selectedChatIds.length === (1 + friends.length + myGroups.length) ? 'Bỏ chọn' : 'Chọn Tất Cả'}
) : (
/* Standard Header */
{
setIsChatEditMode(true);
setSelectedChatIds([]);
}}
className="text-vn-wood font-medium text-[14px] sm:text-[15px] bg-white px-3 py-1.5 rounded-full shadow-sm shrink-0 hover:bg-vn-paper transition-colors"
>
Sửa
{window.IS_ARCHIVE_MODE ? 'Tin nhắn lưu trữ' : 'Trò chuyện'}
setShowNewChatModal(true)} className="w-8 h-8 bg-vn-red rounded-full flex items-center justify-center text-white hover:scale-105 active:scale-95 transition-all">
)}
{
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 && (
Đánh dấu tất cả là đã đọc
Xóa
)}
>
)}
{/* TAB: CALLS */}
{activeTab === 'calls' && (
<>
setCallFilter('all')} className={`px-4 py-1 rounded-full text-[15px] font-medium transition-colors ${callFilter === 'all' ? 'bg-white shadow-sm text-vn-wood' : 'text-[#7D6B5D]'}`}>Tất cả
setCallFilter('missed')} className={`px-4 py-1 rounded-full text-[15px] font-medium transition-colors ${callFilter === 'missed' ? 'bg-white shadow-sm text-vn-wood' : 'text-[#7D6B5D]'}`}>Cuộc gọi nhỡ
setShowNewChatModal(true)} className="w-8 h-8 bg-vn-red rounded-full flex items-center justify-center text-white">
{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]} }
{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' && (
<>
{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 */}
setSettingsSubScreen(null)}
className="w-9 h-9 rounded-full bg-white shadow-sm flex items-center justify-center text-[#7D6B5D] hover:bg-gray-50 active:scale-95 transition-all"
>
Hồ sơ
{
setEditName(user.name || '');
setEditBio(user.bio || '');
setEditAvatarUrl(user.avatarUrl || '');
setShowEditProfileModal(true);
}}
className="px-4 py-1.5 rounded-full bg-white text-vn-wood hover:bg-vn-wood hover:text-white font-bold text-[13px] shadow-sm transition-all active:scale-95 border border-gray-100"
>
Chỉnh sửa
{/* 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 */}
setShowLogoutModal(true)}
className="w-full bg-white hover:bg-red-50 text-red-500 hover:text-red-600 font-bold py-3.5 rounded-[24px] shadow-sm border border-red-100 flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
>
Đăng xuất
{/* 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 */}
setSettingsSubScreen(null)}
className="w-9 h-9 rounded-full bg-white shadow-sm flex items-center justify-center text-[#7D6B5D] hover:bg-gray-50 active:scale-95 transition-all"
>
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 */}
setSettingsSubScreen(null)}
className="w-9 h-9 rounded-full bg-white shadow-sm flex items-center justify-center text-[#7D6B5D] hover:bg-gray-50 active:scale-95 transition-all"
>
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):
Nhấp vào nút Chia sẻ ở dưới cùng Safari.
Cuộn xuống và chọn Thêm vào MH chính (Add to Home Screen) .
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 NGAY
) : (
Để 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"}
{
navigator.clipboard.writeText(user.userId || user.id || "");
}} className="w-10 h-10 bg-vn-paper rounded-full flex items-center justify-center text-gray-600 hover:bg-[#E8E2D2]">
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">
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 && (
{ setActiveTab('chats'); setActiveChat(null); }} className={`flex flex-col items-center justify-center flex-1 py-2 rounded-[24px] transition-all ${activeTab === 'chats' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D]'}`}>
Tin nhắn
{ setActiveTab('calls'); setActiveChat(null); }} className={`flex flex-col items-center justify-center flex-1 py-2 rounded-[24px] transition-all ${activeTab === 'calls' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D]'}`}>
Cuộc gọi
{ setActiveTab('contacts'); setActiveChat(null); }} className={`flex flex-col items-center justify-center flex-1 py-2 rounded-[24px] transition-all ${activeTab === 'contacts' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D]'}`}>
Danh bạ
{ setActiveTab('settings'); setActiveChat(null); }} className={`flex flex-col items-center justify-center flex-1 py-2 rounded-[24px] transition-all ${activeTab === 'settings' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D]'}`}>
Cài đặt
{user?.is_admin == 1 && (
{ setActiveTab('admin'); setActiveChat(null); setAdminSubTab(null); }} className={`flex flex-col items-center justify-center flex-1 py-2 rounded-[24px] transition-all ${activeTab === 'admin' ? 'bg-vn-red/10 text-vn-red' : 'text-[#7D6B5D]'}`}>
Quản lý
)}
)}
{/* CHAT AREA (Detail) */}
{activeTab === 'admin' && user?.is_admin == 1 && adminSubTab ? (
setAdminSubTab(null)} className="md:hidden w-10 h-10 -ml-2 mr-2 flex items-center justify-center text-gray-600 rounded-full hover:bg-gray-100">
{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'}
Làm mới
{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 ?
:
}
handleBanUser(u.userId, u.is_banned)} className={`px-4 py-2 rounded-full text-[14px] font-medium flex items-center gap-1.5 transition-colors ${u.is_banned == 1 ? 'bg-green-100 text-green-700 hover:bg-green-200' : 'bg-orange-100 text-orange-700 hover:bg-orange-200'}`}>
{u.is_banned == 1 ? 'Mở khóa' : 'Khóa'}
handleDeleteUser(u.userId)} className="px-4 py-2 rounded-full text-[14px] font-medium flex items-center gap-1.5 transition-colors bg-red-100 text-red-700 hover:bg-red-200">
Xóa
))}
)}
{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 => (
handleBanUser(u.userId, 1)} className="px-4 py-2 rounded-full text-[14px] mt-3 sm:mt-0 font-medium flex items-center gap-1.5 transition-colors bg-green-100 text-green-700 hover:bg-green-200">
Mở khóa
))}
)}
) : 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 ? (
) : activeChat && (
{/* Header */}
setActiveChat(null)}>
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)}
{
setShowMessageSearch(prev => !prev);
setMessageSearchQuery('');
}}
className={`w-9 h-9 rounded-full flex items-center justify-center transition-colors ${
showMessageSearch
? 'bg-vn-red/10 text-vn-red animate-pulse'
: 'text-[#7D6B5D] hover:bg-vn-paper'
}`}
title="Tìm kiếm tin nhắn"
>
{activeChat.userId !== user.userId && (
<>
startCall(false)} title="Gọi thoại">
startCall(true)} title="Gọi Video">
>
)}
{/* Message Search Bar */}
{showMessageSearch && (
)}
{/* 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.
{
setPendingLoginUser({ ...user, tmpPubKey: localStorage.getItem(`pubKey_${user.userId}`) });
setShowRecoveryInput(true);
}}
className="w-full bg-vn-red text-white py-3.5 rounded-2xl font-bold hover:bg-vn-red/90 transition-all active:scale-95 shadow-md shadow-vn-red/10 mt-2 text-[15px]"
>
Nhập mã PIN xác minh
) : (
/* 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 (
);
}
// Hiển thị tin nhắn bị chặn (system_block)
if (msg.type === 'system_block') {
return (
);
}
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 && (
{
e.stopPropagation();
setContextMenu({ x: 0, y: 0, msg });
togglePinMessage();
}}
className="p-1.5 bg-white rounded-full shadow-sm hover:bg-gray-100 transition-colors"
>
{pinnedMessages[`${user.userId}_${activeChat.userId}_${msg.id}`] ?
:
}
)}
{senderName && (
{senderName}
)}
{msg.type === 'image' &&
setViewingImage(msg.fileUrl)} />}
{msg.type === 'video' &&
}
{msg.type === 'audio' &&
}
{(msg.type === 'text' || !msg.type) && (
{isMe && msg.status === 'failed' && (
!
)}
{renderMessageContent(msg.content, isMe)}
{formatTime(msg.timestamp)}
)}
{/* Nút ghim bên phải nếu là tin của mình */}
{isMe && (
{
e.stopPropagation();
setContextMenu({ x: 0, y: 0, msg });
togglePinMessage();
}}
className="p-1.5 bg-white rounded-full shadow-sm hover:bg-gray-100 transition-colors"
>
{pinnedMessages[`${user.userId}_${activeChat.userId}_${msg.id}`] ?
:
}
)}
);
})}
)}
{/* 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.
Mở khóa
) : (
/* Standard Input Area */
<>
{['👍', '❤️', '😂', '🔥', '🎉', '💩', '😢', '😍', '👏', '🤝', '🇻🇳'].map(emoji => (
setMessageInput(prev => prev + emoji)} className="text-[22px] hover:scale-125 transition-transform shrink-0">
{emoji}
))}
>
)}
{showContactInfo && renderContactInfo()}
)}
{/* CONTEXT MENU (Menu chuột phải) */}
{contextMenu && (
{/* Nút xem trước */}
{(contextMenu.msg.type === 'image' || contextMenu.msg.type === 'video') && (
{
if (contextMenu.msg.type === 'image') setViewingImage(contextMenu.msg.fileUrl);
setContextMenu(null);
}}
className="w-full px-4 py-2.5 text-left flex items-center gap-2 hover:bg-gray-100 transition-colors text-[15px]"
>
Xem trước
)}
{/* Nút ghim tin nhắn */}
{pinnedMessages[`${user.userId}_${activeChat.userId}_${contextMenu.msg.id}`] ?
:
}
{pinnedMessages[`${user.userId}_${activeChat.userId}_${contextMenu.msg.id}`] ? 'Bỏ ghim' : '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 && (
Xóa tin nhắn (2 chiều)
)}
)}
{/* CONTEXT MENU DANH SÁCH (Menu chuột phải trên PC) */}
{listContextMenu && (
{listContextMenu.friend.is_unread_db == 1 || unreadMessages[listContextMenu.friend.userId] > 0 ? 'Đánh dấu là đã đọc' : 'Đánh dấu là chưa đọc'}
{ togglePinFriend(e, listContextMenu.friend.userId); setListContextMenu(null); }}
className="w-full px-4 py-2.5 text-left flex items-center gap-3 hover:bg-gray-100 transition-colors text-[15px] font-medium"
>
{pinnedFriends.includes(listContextMenu.friend.userId) ? 'Bỏ ghim cuộc trò chuyện' : 'Ghim cuộc trò chuyện'}
{
const targetFriend = listContextMenu.friend;
setListContextMenu(null);
setHiddenPinTargetChat(targetFriend);
if (hiddenChatsPin && hiddenChatsPin.length > 0) {
setHiddenPinModalMode('enter_to_hide');
} else {
setHiddenPinModalMode('setup');
}
setHiddenPinInputVal('');
setHiddenPinError('');
setShowHiddenPinModal(true);
}}
className="w-full px-4 py-2.5 text-left flex items-center gap-3 hover:bg-red-50 text-red-600 transition-colors text-[15px] font-bold border-t border-gray-100"
>
Ẩn cuộc trò chuyện
)}
{/* MODAL XEM ẢNH FULLSCREEN */}
{viewingImage && (
setViewingImage(null)}
onKeyDown={(e) => e.key === 'Escape' && setViewingImage(null)}
>
{ e.stopPropagation(); setViewingImage(null); }}
>
e.stopPropagation()}
/>
)}
{/* DIALPAD MODAL */}
{showDialpad && (
{ setShowDialpad(false); setDialNumber(''); }} className="absolute top-4 left-4 w-10 h-10 bg-gray-100 rounded-full flex items-center justify-center text-vn-wood">
Nhập số Lutos
Dán
{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 => (
{
if (dialNumber.replace('-', '').length < 8) { // 3 digits + 5 digits = 8 digits maximum
let newNum = dialNumber.replace('-', '') + key.num;
if (newNum.length > 3) newNum = newNum.slice(0, 3) + '-' + newNum.slice(3);
setDialNumber(newNum);
}
}}
className="w-[72px] h-[72px] rounded-full bg-[#F2F4F7] flex flex-col items-center justify-center active:bg-gray-300 transition-colors mx-auto"
>
{key.num}
{key.letters && {key.letters} }
))}
{
if (!dialNumber) return;
const cleanId = dialNumber.replace('-', '');
try {
const res = await axios.get(`${API_BASE}?action=get_user&userId=${cleanId}&requesterId=${user.userId}&t=${Date.now()}`);
if (res.data && res.data.userId) {
const friend = res.data;
const savedFriends = JSON.parse(localStorage.getItem(`friends_${user.userId}`) || '[]');
if (!savedFriends.find(f => f.userId === friend.userId)) {
const updatedFriends = [...savedFriends, friend];
setFriends(updatedFriends);
localStorage.setItem(`friends_${user.userId}`, JSON.stringify(updatedFriends));
}
setActiveChat(friend);
setShowDialpad(false);
setTimeout(() => {
startCall(false); // Gọi thoại ngay lập tức
}, 500);
} else {
alert('Số không tồn tại!');
}
} catch (err) { alert('Số không tồn tại!'); }
}}
className="w-[72px] h-[72px] rounded-full bg-[#4285F4] text-white flex items-center justify-center shadow-md active:bg-blue-600 transition-colors"
>
{dialNumber && (
{
let raw = dialNumber.replace('-', '');
raw = raw.slice(0, -1);
if (raw.length > 3) setDialNumber(raw.slice(0, 3) + '-' + raw.slice(3));
else setDialNumber(raw);
}}
className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 active:text-gray-600 p-2"
>
)}
)}
{/* NEW CHAT MODAL (NÚT +) */}
{showNewChatModal && (
setShowNewChatModal(false)}>
e.stopPropagation()}>
Trò chuyện mới
setShowNewChatModal(false)} className="w-8 h-8 bg-[#E5E5EA] rounded-full flex items-center justify-center text-gray-500">
!
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
alert('Tính năng Cuộc gọi Nhóm đang được phát triển!')} className="w-full flex items-center gap-4 px-4 py-3.5 border-b border-gray-100 active:bg-gray-50">
Cuộc gọi Nhóm Mới
{ setShowNewChatModal(false); setShowCreateGroupModal(true); setGroupNameInput(''); setSelectedGroupMembers([]); }} className="w-full flex items-center gap-4 px-4 py-3.5 border-b border-gray-100 active:bg-gray-50">
Tạo Nhóm
{ setShowNewChatModal(false); setActiveTab('contacts'); }} className="w-full flex items-center gap-4 px-4 py-3.5 active:bg-gray-50">
Thêm Liên hệ
)}
{/* CREATE GROUP MODAL */}
{showCreateGroupModal && (
setShowCreateGroupModal(false)}>
e.stopPropagation()}>
{/* Header */}
{ setShowCreateGroupModal(false); setShowNewChatModal(true); }} className="text-vn-red font-medium text-[16px]">Quay lại
Tạo Nhóm Mới
setShowCreateGroupModal(false)} className="w-8 h-8 bg-[#E5E5EA] rounded-full flex items-center justify-center text-gray-500">
{/* Body */}
{/* Group Name input */}
{/* Member Selection list */}
Chọn thành viên (Tối thiểu 2 người khác)
{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}
);
})}
)}
{/* 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).
)}
{
setIsCreatingGroup(true);
try {
const res = await axios.post(`${API_BASE}?action=create_group`, {
groupName: groupNameInput,
creatorId: user.userId,
memberIds: selectedGroupMembers
});
if (res.data && res.data.success) {
showToast('Thành công', `Đã tạo nhóm "${groupNameInput}" thành công!`);
setShowCreateGroupModal(false);
fetchGroups();
// Tự động mở chat của nhóm mới tạo!
setActiveChat(res.data.group);
}
} catch (e) {
showToast('Lỗi', e.response?.data?.error || 'Không thể tạo nhóm');
} finally {
setIsCreatingGroup(false);
}
}}
className={`w-full py-3.5 rounded-[16px] text-white font-bold text-[16px] shadow-sm flex items-center justify-center gap-2 transition-all ${(!groupNameInput.trim() || selectedGroupMembers.length < 2) ? 'bg-gray-300 cursor-not-allowed shadow-none' : 'bg-vn-red hover:bg-vn-red/90 active:scale-[0.98]'}`}
>
{isCreatingGroup ? (
<>
Đang tạo nhóm...
>
) : (
Tạo Nhóm Trò Chuyệ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 ?
:
}
editAvatarInputRef.current?.click()}
className="mt-2 text-xs font-semibold text-vn-red hover:underline"
>
Thay đổi ảnh đại diện
{/* Display Name Input */}
Tên hiển thị
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 */}
Tiểu sử
{/* Actions */}
setShowEditProfileModal(false)}
className="flex-1 py-3 bg-gray-100 hover:bg-gray-200 text-vn-wood font-bold rounded-2xl transition-colors active:scale-95 text-[15px]"
>
Hủy
{isSavingProfile ? (
<>
Đang lưu...
>
) : (
Lưu thay đổi
)}
)}
{/* CHANGE PASSWORD MODAL */}
{showChangePasswordModal && (
Đổi mật khẩu tài khoản
Mật khẩu này dùng để đăng nhập vào tài khoản Lutos của bạn ở các thiết bị mới. Vui lòng ghi nhớ kỹ!
{/* Current Password */}
Mật khẩu hiện tại
{ setCurrentPassword(e.target.value); setChangePasswordError(''); }}
placeholder="Nhập mật khẩu cũ"
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"
/>
{/* New Password */}
Mật khẩu mới
{ setNewPassword(e.target.value); setChangePasswordError(''); }}
placeholder="Nhập ít nhất 8 ký tự..."
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"
/>
{/* Confirm New Password */}
Xác nhận mật khẩu mới
{ setConfirmPassword(e.target.value); setChangePasswordError(''); }}
placeholder="Nhập lại mật khẩu mới..."
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"
/>
{changePasswordError && (
{changePasswordError}
)}
{/* Actions */}
setShowChangePasswordModal(false)}
className="flex-1 py-3 bg-gray-100 hover:bg-gray-200 text-vn-wood font-bold rounded-2xl transition-colors active:scale-95 text-[15px]"
>
Hủy
{isChangingPassword ? (
<>
Đang cập nhật...
>
) : (
Cập nhật
)}
)}
{/* LOGOUT MODAL */}
{showLogoutModal && (
Đăng xuất
Bạn đang chuẩn bị đăng xuất. Theo cơ chế bảo mật, khóa bảo mật trên thiết bị này sẽ bị xóa nếu bạn chọn Xóa sạch dữ liệu .
Nếu vậy, lần sau đăng nhập trên máy này bạn sẽ không thể đọc lại tin nhắn cũ. Bạn có muốn giữ lại khóa giải mã không?
{
localStorage.removeItem('chat_user');
window.location.reload();
}}
className="w-full py-3.5 bg-green-500 hover:bg-green-600 text-white rounded-xl font-bold shadow-sm transition-colors"
>
Giữ lại (Khuyên dùng)
{
localStorage.removeItem(`privKey_${user.userId}`);
localStorage.removeItem(`pubKey_${user.userId}`);
localStorage.removeItem('chat_user');
window.location.reload();
}}
className="w-full py-3.5 bg-red-50 hover:bg-red-100 text-red-600 rounded-xl font-bold border border-red-100 transition-colors"
>
Xóa sạch dữ liệu (Mất tin cũ)
setShowLogoutModal(false)}
className="w-full py-3.5 bg-gray-100 hover:bg-gray-200 text-gray-700 rounded-xl font-semibold transition-colors"
>
Hủy
)}
{/* MODAL: NHẬP/THIẾT LẬP MÃ PIN CHO TRÒ CHUYỆN ẨN */}
{showHiddenPinModal && (
{hiddenPinModalMode === 'setup' && 'Thiết lập mã PIN ẩn'}
{hiddenPinModalMode === 'confirm_setup' && 'Xác nhận mã PIN ẩn'}
{hiddenPinModalMode === 'enter_to_hide' && 'Xác thực mã PIN ẩn'}
{hiddenPinModalMode === 'enter_to_manage' && 'Xác thực truy cập'}
{hiddenPinModalMode === 'setup' && 'Nhập 4 chữ số để tạo mã PIN bảo vệ các cuộc trò chuyện ẩn của bạn.'}
{hiddenPinModalMode === 'confirm_setup' && 'Vui lòng nhập lại mã PIN một lần nữa để xác nhận.'}
{hiddenPinModalMode === 'enter_to_hide' && `Nhập mã PIN ẩn để ẩn cuộc trò chuyện này.`}
{hiddenPinModalMode === 'enter_to_manage' && 'Nhập mã PIN để truy cập kho lưu trữ cuộc trò chuyện ẩn.'}
{/* Circular indicators */}
{[0, 1, 2, 3].map((idx) => (
idx
? 'bg-vn-red border-vn-red scale-110 shadow-sm'
: 'border-gray-300 bg-transparent'
}`}
/>
))}
Dán
{hiddenPinError && (
{hiddenPinError}
)}
{/* Numeric Keypad */}
{[1, 2, 3, 4, 5, 6, 7, 8, 9].map((num) => (
{
if (hiddenPinInputVal.length < 4) {
const newVal = hiddenPinInputVal + num;
setHiddenPinInputVal(newVal);
setHiddenPinError('');
if (newVal.length === 4) {
setTimeout(() => handleHiddenPinSubmit(newVal), 200);
}
}
}}
className="h-14 rounded-2xl bg-gray-50 hover:bg-gray-100 text-vn-wood font-extrabold text-[20px] shadow-sm transition-all active:scale-90"
>
{num}
))}
{
setHiddenPinInputVal('');
setHiddenPinError('');
}}
className="h-14 rounded-2xl bg-gray-100/60 hover:bg-gray-100 text-gray-500 font-bold text-[14px] active:scale-90 flex items-center justify-center"
>
Xóa
{
if (hiddenPinInputVal.length < 4) {
const newVal = hiddenPinInputVal + '0';
setHiddenPinInputVal(newVal);
setHiddenPinError('');
if (newVal.length === 4) {
setTimeout(() => handleHiddenPinSubmit(newVal), 200);
}
}
}}
className="h-14 rounded-2xl bg-gray-50 hover:bg-gray-100 text-vn-wood font-extrabold text-[20px] shadow-sm transition-all active:scale-90"
>
0
{
if (hiddenPinInputVal.length > 0) {
setHiddenPinInputVal(prev => prev.slice(0, -1));
setHiddenPinError('');
}
}}
className="h-14 rounded-2xl bg-gray-100/60 hover:bg-gray-100 text-gray-600 font-bold text-[18px] active:scale-90 flex items-center justify-center"
>
←
setShowHiddenPinModal(false)}
className="w-full py-3 bg-gray-100 hover:bg-gray-200 text-gray-700 font-bold rounded-2xl transition-colors active:scale-95 text-[15px]"
>
Hủy bỏ
)}
{/* MODAL: DANH SÁCH CUỘC TRÒ CHUYỆN ĐÃ ẨN */}
{showHiddenChatsList && (
Kho Lưu Trữ Ẩn
setShowHiddenChatsList(false)}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-gray-500"
>
{hiddenChats.length === 0 ? (
Không có trò chuyện ẩn
Hãy nhấn chuột phải vào cuộc trò chuyện bất kỳ ở trang chủ (hoặc nhấn giữ/vào cài đặt liên hệ trên điện thoại) và chọn "Ẩn cuộc trò chuyện".
) : (
[...friends, ...myGroups].filter(f => hiddenChats.includes(f.userId)).map(friend => {
const isGrp = friend.is_group === true || friend.userId.startsWith('group_');
return (
{isGrp ? (
) : (
friend.avatarUrl ?
:
{friend.name[0]}
)}
{friend.name}
Số Lutos: {friend.userId}
{
const updated = hiddenChats.filter(id => id !== friend.userId);
localStorage.setItem(`hiddenChats_${user.userId}`, JSON.stringify(updated));
setHiddenChats(updated);
showToastAlert("Đã gỡ ẩn", `Đã gỡ ẩn cuộc trò chuyện với ${friend.name}`);
}}
className="px-3.5 py-1.5 rounded-full bg-green-50 hover:bg-green-100 text-green-600 font-extrabold text-[12.5px] shadow-sm transition-all active:scale-95 border border-green-100 shrink-0"
>
Gỡ ẩn
);
})
)}
{
if (confirm("Bạn có chắc chắn muốn xóa mã PIN ẩn tin nhắn? Toàn bộ các cuộc trò chuyện ẩn sẽ được hiển thị trở lại màn hình chính.")) {
localStorage.removeItem(`hiddenChatsPin_${user.userId}`);
localStorage.removeItem(`hiddenChats_${user.userId}`);
setHiddenChatsPin('');
setHiddenChats([]);
setShowHiddenChatsList(false);
showToastAlert("Thành công", "Đã xóa mã PIN và hiển thị lại toàn bộ trò chuyện!");
}
}}
className="flex-1 py-3 bg-red-50 hover:bg-red-100 text-red-600 font-bold rounded-2xl transition-colors text-[13.5px]"
>
Xóa mã PIN
setShowHiddenChatsList(false)}
className="flex-1 py-3 bg-vn-wood hover:bg-vn-wood-light text-white font-bold rounded-2xl shadow-sm transition-all text-[13.5px]"
>
Đóng
)}
{/* MODAL: DANH SÁCH NGƯỜI DÙNG ĐÃ CHẶN */}
{showBlockedUsersList && (
Danh sách chặn
setShowBlockedUsersList(false)}
className="w-8 h-8 rounded-full bg-gray-100 hover:bg-gray-200 flex items-center justify-center text-gray-500"
>
{!Array.isArray(blockedUsers) || blockedUsers.length === 0 ? (
Không có người dùng bị chặn
Để chặn ai đó, hãy vào xem trang thông tin liên hệ của họ và chọn "Chặn liên hệ này".
) : (
blockedUsers.map(blockedUser => (
{blockedUser.avatarUrl ?
:
{blockedUser.name[0]} }
{blockedUser.name}
Số Lutos: {blockedUser.userId}
handleUnblockUser(blockedUser.userId, blockedUser.name)}
className="px-3.5 py-1.5 rounded-full bg-gray-50 hover:bg-gray-100 text-vn-wood font-extrabold text-[12.5px] shadow-sm transition-all active:scale-95 border border-gray-200 shrink-0"
>
Bỏ chặn
))
)}
setShowBlockedUsersList(false)}
className="w-full py-3 bg-vn-wood hover:bg-vn-wood-light text-white font-bold rounded-2xl shadow-sm transition-all text-[14px]"
>
Đóng
)}
{/* Toast Notification */}
{toast && (
!
{toast.title}
{toast.message}
setToast(null)} className="text-gray-400 hover:text-white">
)}
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
);