import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react'; import { useQuery, useConvex } from 'convex/react'; import { api } from '../../../../convex/_generated/api'; import Sidebar from '../components/Sidebar'; import ChatArea from '../components/ChatArea'; import VoiceStage from '../components/VoiceStage'; import FloatingStreamPiP from '../components/FloatingStreamPiP'; import { useVoice } from '../contexts/VoiceContext'; import FriendsView from '../components/FriendsView'; import MembersList from '../components/MembersList'; import ChatHeader from '../components/ChatHeader'; import SearchPanel from '../components/SearchPanel'; import SearchDropdown from '../components/SearchDropdown'; import { useToasts } from '../components/Toast'; import { PresenceProvider } from '../contexts/PresenceContext'; import { getUserPref, setUserPref } from '../utils/userPreferences'; import { usePlatform } from '../platform'; import { useIsMobile } from '../hooks/useIsMobile'; import IncomingCallUI from '../components/IncomingCallUI'; import Avatar from '../components/Avatar'; import callRingSound from '../assets/sounds/default_call_sound.mp3'; const MAX_SEARCH_HISTORY = 10; const Chat = () => { const { crypto, settings } = usePlatform(); const isMobile = useIsMobile(); const [userId, setUserId] = useState(() => localStorage.getItem('userId')); const [username, setUsername] = useState(() => localStorage.getItem('username') || ''); const [view, setView] = useState(() => { const id = localStorage.getItem('userId'); return id ? getUserPref(id, 'lastView', 'server') : 'server'; }); const [activeChannel, setActiveChannel] = useState(null); const [channelKeys, setChannelKeys] = useState({}); const [activeDMChannel, setActiveDMChannel] = useState(null); const [showMembers, setShowMembers] = useState(true); const [showPinned, setShowPinned] = useState(false); const [mobileView, setMobileView] = useState('sidebar'); // Jump-to-message state (for search result clicks) const [jumpToMessageId, setJumpToMessageId] = useState(null); const clearJumpToMessage = useCallback(() => setJumpToMessageId(null), []); // Search state const [searchQuery, setSearchQuery] = useState(''); const [showSearchDropdown, setShowSearchDropdown] = useState(false); const [showSearchResults, setShowSearchResults] = useState(false); const [searchSortOrder, setSearchSortOrder] = useState('newest'); const [searchHistory, setSearchHistory] = useState(() => { const id = localStorage.getItem('userId'); return id ? getUserPref(id, 'searchHistory', []) : []; }); const searchInputRef = useRef(null); const convex = useConvex(); const { toasts, addToast, removeToast, ToastContainer } = useToasts(); const prevDmChannelsRef = useRef(null); const { toggleMute, connectToVoice, disconnectVoice, activeChannelId: voiceActiveChannelId, voiceStates, room } = useVoice(); // Keyboard shortcuts useEffect(() => { const handler = (e) => { if (e.ctrlKey && e.key === 'k') { e.preventDefault(); // Focus the search input const input = searchInputRef.current?.querySelector('input'); if (input) { input.focus(); } } if (e.ctrlKey && e.shiftKey && e.key === 'M') { e.preventDefault(); toggleMute(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [toggleMute]); const channels = useQuery(api.channels.list) || []; const categories = useQuery(api.categories.list) || []; const serverSettings = useQuery(api.serverSettings.get); const serverName = serverSettings?.serverName || 'Secure Chat'; const serverIconUrl = serverSettings?.iconUrl || null; const allMembers = useQuery(api.members.listAll) || []; const rawChannelKeys = useQuery( api.channelKeys.getKeysForUser, userId ? { userId } : "skip" ); const dmChannels = useQuery( api.dms.listDMs, userId ? { userId } : "skip" ) || []; useEffect(() => { if (!rawChannelKeys || rawChannelKeys.length === 0) return; const privateKey = sessionStorage.getItem('privateKey'); if (!privateKey) return; async function decryptKeys() { const keys = {}; for (const item of rawChannelKeys) { try { const bundleJson = await crypto.privateDecrypt(privateKey, item.encrypted_key_bundle); Object.assign(keys, JSON.parse(bundleJson)); } catch (e) { console.error(`Failed to decrypt keys for channel ${item.channel_id}`, e); } } setChannelKeys(keys); } decryptKeys(); }, [rawChannelKeys]); useEffect(() => { if (activeChannel || channels.length === 0) return; // Try to restore last active channel if (userId) { const savedChannel = getUserPref(userId, 'lastActiveChannel', null); if (savedChannel && channels.some(c => c._id === savedChannel)) { setActiveChannel(savedChannel); return; } } const firstTextChannel = channels.find(c => c.type === 'text'); if (firstTextChannel) { setActiveChannel(firstTextChannel._id); } }, [channels, activeChannel, userId]); // Persist active channel useEffect(() => { if (activeChannel && userId) { setUserPref(userId, 'lastActiveChannel', activeChannel, settings); } }, [activeChannel, userId]); // Persist view mode useEffect(() => { if (userId) { setUserPref(userId, 'lastView', view, settings); } }, [view, userId]); const openDM = useCallback(async (targetUserId, targetUsername) => { const uid = localStorage.getItem('userId'); if (!uid) return; try { const { channelId, created } = await convex.mutation(api.dms.openDM, { userId: uid, targetUserId }); if (created) { const keyHex = await crypto.randomBytes(32); const allUsers = await convex.query(api.auth.getPublicKeys, {}); const participants = allUsers.filter(u => u.id === uid || u.id === targetUserId); const batchKeys = []; for (const u of participants) { if (!u.public_identity_key) continue; try { const payload = JSON.stringify({ [channelId]: keyHex }); const encryptedKeyHex = await crypto.publicEncrypt(u.public_identity_key, payload); batchKeys.push({ channelId, userId: u.id, encryptedKeyBundle: encryptedKeyHex, keyVersion: 1 }); } catch (e) { console.error('Failed to encrypt DM key for user', u.id, e); } } if (batchKeys.length > 0) { await convex.mutation(api.channelKeys.uploadKeys, { keys: batchKeys }); } } setActiveDMChannel({ channel_id: channelId, other_username: targetUsername }); setView('me'); if (isMobile) setMobileView('chat'); } catch (err) { console.error('Error opening DM:', err); } }, [convex, isMobile]); const handleSelectChannel = useCallback((channelId) => { setActiveChannel(channelId); setShowPinned(false); if (isMobile) setMobileView('chat'); }, [isMobile]); const handleMobileBack = useCallback(() => { setMobileView('sidebar'); }, []); const activeChannelObj = channels.find(c => c._id === activeChannel); const { watchingStreamOf } = useVoice(); const isDMView = view === 'me' && activeDMChannel; const isServerTextChannel = view === 'server' && activeChannel && activeChannelObj?.type !== 'voice'; const currentChannelId = isDMView ? activeDMChannel.channel_id : activeChannel; const effectiveShowMembers = isMobile ? false : showMembers; // DM call state const dmCallActive = isDMView && activeDMChannel ? (voiceStates[activeDMChannel.channel_id]?.length > 0) : false; const isInDMCall = isDMView && activeDMChannel ? voiceActiveChannelId === activeDMChannel.channel_id : false; const [dmCallExpanded, setDmCallExpanded] = useState(false); const [dmCallStageHeight, setDmCallStageHeight] = useState(45); const [rejectedCallChannelId, setRejectedCallChannelId] = useState(null); const ringAudioRef = useRef(null); const ringTimeoutRef = useRef(null); useEffect(() => { if (!isInDMCall) setDmCallExpanded(false); }, [isInDMCall]); // Global incoming DM call detection — not gated by current view const incomingDMCall = useMemo(() => { if (!userId) return null; for (const dm of dmChannels) { const users = voiceStates[dm.channel_id] || []; if (users.length > 0 && voiceActiveChannelId !== dm.channel_id) { const caller = users.find(u => u.userId !== userId); if (caller) { return { channelId: dm.channel_id, otherUsername: dm.other_username, callerUsername: caller.username, callerAvatarUrl: caller.avatarUrl || null, }; } } } return null; }, [dmChannels, voiceStates, voiceActiveChannelId, userId]); // Play ringing sound for incoming DM calls (global — works from any view) useEffect(() => { if (incomingDMCall && incomingDMCall.channelId !== rejectedCallChannelId) { const audio = new Audio(callRingSound); audio.loop = true; audio.volume = 0.5; audio.play().catch(() => {}); ringAudioRef.current = audio; ringTimeoutRef.current = setTimeout(() => { audio.pause(); audio.currentTime = 0; setRejectedCallChannelId(incomingDMCall.channelId); }, 30000); return () => { audio.pause(); audio.currentTime = 0; ringAudioRef.current = null; clearTimeout(ringTimeoutRef.current); ringTimeoutRef.current = null; }; } }, [incomingDMCall?.channelId, rejectedCallChannelId]); // Clear rejected state when the rejected call's channel becomes empty useEffect(() => { if (rejectedCallChannelId && !(voiceStates[rejectedCallChannelId]?.length > 0)) { setRejectedCallChannelId(null); } }, [rejectedCallChannelId, voiceStates]); const handleDmCallResizeStart = useCallback((e) => { e.preventDefault(); const chatContent = e.target.closest('.chat-content'); if (!chatContent) return; const startY = e.clientY; const contentRect = chatContent.getBoundingClientRect(); const startHeight = dmCallStageHeight; const onMouseMove = (moveE) => { const deltaY = moveE.clientY - startY; const deltaPercent = (deltaY / contentRect.height) * 100; const newHeight = Math.min(80, Math.max(20, startHeight + deltaPercent)); setDmCallStageHeight(newHeight); }; const onMouseUp = () => { document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }, [dmCallStageHeight]); const handleStartDMCall = useCallback(async () => { if (!activeDMChannel) return; const dmChannelId = activeDMChannel.channel_id; const otherUsername = activeDMChannel.other_username; // Already in this DM call if (voiceActiveChannelId === dmChannelId) return; // Suppress ringing when we leave later — just show the banner setRejectedCallChannelId(dmChannelId); // If in another voice channel, disconnect first if (voiceActiveChannelId) { disconnectVoice(); // Brief delay for cleanup await new Promise(r => setTimeout(r, 300)); } // Check if this is a new call (no one currently in it) const isNewCall = !voiceStates[dmChannelId]?.length; connectToVoice(dmChannelId, otherUsername, userId, true); // Send system message for new calls if (isNewCall && channelKeys[dmChannelId]) { try { const systemContent = JSON.stringify({ type: 'system', text: `${username} started a call` }); const { content: encryptedContent, iv, tag } = await crypto.encryptData(systemContent, channelKeys[dmChannelId]); const ciphertext = encryptedContent + tag; const signingKey = sessionStorage.getItem('signingKey'); if (signingKey) { await convex.mutation(api.messages.send, { channelId: dmChannelId, senderId: userId, ciphertext, nonce: iv, signature: await crypto.signMessage(signingKey, ciphertext), keyVersion: 1 }); } } catch (e) { console.error('Failed to send call system message:', e); } } }, [activeDMChannel, voiceActiveChannelId, disconnectVoice, connectToVoice, userId, username, channelKeys, crypto, convex, voiceStates]); // PiP: show when watching a stream and NOT currently viewing the voice channel in VoiceStage const isViewingDMCallStage = isDMView && isInDMCall; const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage; const showPiP = watchingStreamOf !== null && !isViewingVoiceStage; const handleGoBackToStream = useCallback(() => { if (voiceActiveChannelId) { setActiveChannel(voiceActiveChannelId); setView('server'); } }, [voiceActiveChannelId]); // Search handlers const handleSearchQueryChange = useCallback((val) => { setSearchQuery(val); if (val === '') { setShowSearchResults(false); } if (!showSearchDropdown && val !== undefined) { setShowSearchDropdown(true); } }, [showSearchDropdown]); const handleSearchFocus = useCallback(() => { setShowSearchDropdown(true); }, []); const handleSearchBlur = useCallback(() => { // Dropdown close is handled by click-outside in SearchDropdown }, []); const handleSearchSubmit = useCallback(() => { if (!searchQuery.trim()) return; setShowSearchDropdown(false); setShowSearchResults(true); // Save to history setSearchHistory(prev => { const filtered = prev.filter(h => h !== searchQuery.trim()); const updated = [searchQuery.trim(), ...filtered].slice(0, MAX_SEARCH_HISTORY); if (userId) { setUserPref(userId, 'searchHistory', updated, settings); } return updated; }); }, [searchQuery, userId, settings]); const handleSelectFilter = useCallback((prefix, value) => { if (value !== null) { // Replace the current active prefix with the completed token const beforePrefix = searchQuery.replace(/\b(from|in|has|mentions):\S*$/i, '').trimEnd(); const newQuery = beforePrefix + (beforePrefix ? ' ' : '') + prefix + ':' + value + ' '; setSearchQuery(newQuery); } else { // Just insert the prefix (e.g., clicking "from:" suggestion) const newQuery = searchQuery + (searchQuery && !searchQuery.endsWith(' ') ? ' ' : '') + prefix + ':'; setSearchQuery(newQuery); } // Re-focus input setTimeout(() => { const input = searchInputRef.current?.querySelector('input'); if (input) input.focus(); }, 0); }, [searchQuery]); const handleSelectHistoryItem = useCallback((item) => { setSearchQuery(item); setShowSearchDropdown(false); setShowSearchResults(true); }, []); const handleClearHistory = useCallback(() => { setSearchHistory([]); if (userId) { setUserPref(userId, 'searchHistory', [], settings); } }, [userId, settings]); const handleClearHistoryItem = useCallback((index) => { setSearchHistory(prev => { const updated = prev.filter((_, i) => i !== index); if (userId) { setUserPref(userId, 'searchHistory', updated, settings); } return updated; }); }, [userId, settings]); const handleCloseSearchDropdown = useCallback(() => { setShowSearchDropdown(false); }, []); const handleCloseSearchResults = useCallback(() => { setShowSearchResults(false); setSearchQuery(''); }, []); const handleJumpToMessage = useCallback((channelId, messageId) => { // Switch to the correct channel if needed const isDM = dmChannels.some(dm => dm.channel_id === channelId); if (isDM) { const dm = dmChannels.find(d => d.channel_id === channelId); if (dm) { setActiveDMChannel(dm); setView('me'); } } else { setActiveChannel(channelId); setView('server'); } setShowSearchResults(false); setSearchQuery(''); setJumpToMessageId(messageId); }, [dmChannels]); // Shared search props for ChatHeader const searchProps = { searchQuery, onSearchQueryChange: handleSearchQueryChange, onSearchSubmit: handleSearchSubmit, onSearchFocus: handleSearchFocus, onSearchBlur: handleSearchBlur, searchInputRef, searchActive: showSearchDropdown || showSearchResults, }; function renderMainContent() { if (view === 'me') { if (activeDMChannel) { const showIncomingUI = dmCallActive && !isInDMCall && incomingDMCall?.channelId === activeDMChannel?.channel_id && rejectedCallChannelId !== activeDMChannel?.channel_id; return (
No channels found.
Click the + in the sidebar to create your first encrypted channel.