All checks were successful
Build and Release / build-and-release (push) Successful in 11m38s
725 lines
32 KiB
JavaScript
725 lines
32 KiB
JavaScript
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 (
|
|
<div className="chat-container">
|
|
<ChatHeader
|
|
channelName={activeDMChannel.other_username}
|
|
channelType="dm"
|
|
onToggleMembers={() => {}}
|
|
showMembers={false}
|
|
onTogglePinned={() => setShowPinned(p => !p)}
|
|
isMobile={isMobile}
|
|
onMobileBack={handleMobileBack}
|
|
onStartCall={handleStartDMCall}
|
|
isDMCallActive={dmCallActive}
|
|
{...searchProps}
|
|
/>
|
|
<div className="chat-content" style={dmCallActive || isInDMCall ? { flexDirection: 'column' } : undefined}>
|
|
{showIncomingUI && (
|
|
<IncomingCallUI
|
|
callerUsername={incomingDMCall.callerUsername}
|
|
callerAvatarUrl={incomingDMCall.callerAvatarUrl}
|
|
onJoin={handleStartDMCall}
|
|
onReject={() => setRejectedCallChannelId(incomingDMCall.channelId)}
|
|
/>
|
|
)}
|
|
{dmCallActive && !isInDMCall && !showIncomingUI && (
|
|
<div className="dm-call-idle-stage">
|
|
<Avatar username={incomingDMCall?.callerUsername || activeDMChannel.other_username} avatarUrl={incomingDMCall?.callerAvatarUrl || null} size={80} />
|
|
<div className="dm-call-idle-username">{activeDMChannel.other_username}</div>
|
|
<div className="dm-call-idle-status">In a call</div>
|
|
<button className="dm-call-join-btn" onClick={handleStartDMCall}>Join Call</button>
|
|
</div>
|
|
)}
|
|
{isInDMCall && (
|
|
<div className="dm-call-stage" style={dmCallExpanded ? { height: '100%', maxHeight: '100%' } : { height: `${dmCallStageHeight}%` }}>
|
|
<button
|
|
className="dm-call-expand-btn"
|
|
onClick={() => setDmCallExpanded(prev => !prev)}
|
|
title={dmCallExpanded ? "Show Chat" : "Hide Chat"}
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"
|
|
style={{ transform: dmCallExpanded ? 'rotate(180deg)' : undefined, transition: 'transform 0.2s' }}>
|
|
<path d="M5.3 9.3a1 1 0 0 1 1.4 0l5.3 5.29 5.3-5.3a1 1 0 1 1 1.4 1.42l-6 6a1 1 0 0 1-1.4 0l-6-6a1 1 0 0 1 0-1.42Z"/>
|
|
</svg>
|
|
</button>
|
|
<VoiceStage room={room} channelId={activeDMChannel.channel_id} voiceStates={voiceStates} channelName={activeDMChannel.other_username} />
|
|
{!dmCallExpanded && <div className="dm-call-resize-handle" onMouseDown={handleDmCallResizeStart} />}
|
|
</div>
|
|
)}
|
|
{(!isInDMCall || !dmCallExpanded) && (
|
|
<div style={{ flex: 1, display: 'flex', overflow: 'hidden' }}>
|
|
<ChatArea
|
|
channelId={activeDMChannel.channel_id}
|
|
channelName={activeDMChannel.other_username}
|
|
channelType="dm"
|
|
channelKey={channelKeys[activeDMChannel.channel_id]}
|
|
username={username}
|
|
userId={userId}
|
|
showMembers={false}
|
|
onToggleMembers={() => {}}
|
|
onOpenDM={openDM}
|
|
showPinned={showPinned}
|
|
onTogglePinned={() => setShowPinned(false)}
|
|
jumpToMessageId={jumpToMessageId}
|
|
onClearJumpToMessage={clearJumpToMessage}
|
|
/>
|
|
<SearchPanel
|
|
visible={showSearchResults}
|
|
onClose={handleCloseSearchResults}
|
|
channels={channels}
|
|
isDM={true}
|
|
dmChannelId={activeDMChannel.channel_id}
|
|
onJumpToMessage={handleJumpToMessage}
|
|
query={searchQuery}
|
|
sortOrder={searchSortOrder}
|
|
onSortChange={setSearchSortOrder}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{isMobile && (
|
|
<div className="chat-header" style={{ position: 'sticky', top: 0, zIndex: 10 }}>
|
|
<div className="chat-header-left">
|
|
<button className="mobile-back-btn" onClick={handleMobileBack}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
|
</button>
|
|
<span className="chat-header-name">Friends</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<FriendsView onOpenDM={openDM} />
|
|
</>
|
|
);
|
|
}
|
|
|
|
if (activeChannel) {
|
|
if (activeChannelObj?.type === 'voice') {
|
|
return (
|
|
<div className="chat-container">
|
|
{isMobile && (
|
|
<div className="chat-header">
|
|
<div className="chat-header-left">
|
|
<button className="mobile-back-btn" onClick={handleMobileBack}>
|
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z"/></svg>
|
|
</button>
|
|
<svg width="20" height="20" viewBox="0 0 24 24" style={{ color: 'var(--text-muted)', marginRight: 4 }}>
|
|
<path fill="currentColor" d="M11.383 3.07904C11.009 2.92504 10.579 3.01004 10.293 3.29604L6.586 7.00304H3C2.45 7.00304 2 7.45304 2 8.00304V16.003C2 16.553 2.45 17.003 3 17.003H6.586L10.293 20.71C10.579 20.996 11.009 21.082 11.383 20.927C11.757 20.772 12 20.407 12 20.003V4.00304C12 3.59904 11.757 3.23404 11.383 3.07904Z" />
|
|
</svg>
|
|
<span className="chat-header-name">{activeChannelObj?.name}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<VoiceStage room={room} channelId={activeChannel} voiceStates={voiceStates} channelName={activeChannelObj?.name} />
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<div className="chat-container">
|
|
<ChatHeader
|
|
channelName={activeChannelObj?.name || activeChannel}
|
|
channelType="text"
|
|
channelTopic={activeChannelObj?.topic}
|
|
onToggleMembers={() => setShowMembers(!showMembers)}
|
|
showMembers={effectiveShowMembers}
|
|
onTogglePinned={() => setShowPinned(p => !p)}
|
|
serverName={serverName}
|
|
isMobile={isMobile}
|
|
onMobileBack={handleMobileBack}
|
|
{...searchProps}
|
|
/>
|
|
<div className="chat-content">
|
|
<ChatArea
|
|
channelId={activeChannel}
|
|
channelName={activeChannelObj?.name || activeChannel}
|
|
channelType="text"
|
|
channelKey={channelKeys[activeChannel]}
|
|
username={username}
|
|
userId={userId}
|
|
showMembers={effectiveShowMembers}
|
|
onToggleMembers={() => setShowMembers(!showMembers)}
|
|
onOpenDM={openDM}
|
|
showPinned={showPinned}
|
|
onTogglePinned={() => setShowPinned(false)}
|
|
jumpToMessageId={jumpToMessageId}
|
|
onClearJumpToMessage={clearJumpToMessage}
|
|
/>
|
|
<MembersList
|
|
channelId={activeChannel}
|
|
visible={effectiveShowMembers}
|
|
onMemberClick={(member) => {}}
|
|
/>
|
|
<SearchPanel
|
|
visible={showSearchResults}
|
|
onClose={handleCloseSearchResults}
|
|
channels={channels}
|
|
onJumpToMessage={handleJumpToMessage}
|
|
query={searchQuery}
|
|
sortOrder={searchSortOrder}
|
|
onSortChange={setSearchSortOrder}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe', flexDirection: 'column' }}>
|
|
<h2>Welcome to {serverName}</h2>
|
|
<p>No channels found.</p>
|
|
<p>Click the <b>+</b> in the sidebar to create your first encrypted channel.</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const handleSetActiveDMChannel = useCallback((dm) => {
|
|
setActiveDMChannel(dm);
|
|
if (isMobile && dm) setMobileView('chat');
|
|
}, [isMobile]);
|
|
|
|
const handleViewChange = useCallback((newView) => {
|
|
setView(newView);
|
|
if (isMobile) setMobileView('sidebar');
|
|
}, [isMobile]);
|
|
|
|
if (!userId) {
|
|
return (
|
|
<div className="app-container">
|
|
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#b9bbbe' }}>
|
|
Loading...
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const showSidebar = !isMobile || mobileView === 'sidebar';
|
|
const showMainContent = !isMobile || mobileView === 'chat';
|
|
|
|
return (
|
|
<PresenceProvider userId={userId}>
|
|
<div className={`app-container${isMobile ? ' is-mobile' : ''}`}>
|
|
{showSidebar && (
|
|
<Sidebar
|
|
channels={channels}
|
|
categories={categories}
|
|
activeChannel={activeChannel}
|
|
onSelectChannel={handleSelectChannel}
|
|
username={username}
|
|
channelKeys={channelKeys}
|
|
view={view}
|
|
onViewChange={handleViewChange}
|
|
onOpenDM={openDM}
|
|
activeDMChannel={activeDMChannel}
|
|
setActiveDMChannel={handleSetActiveDMChannel}
|
|
dmChannels={dmChannels}
|
|
userId={userId}
|
|
serverName={serverName}
|
|
serverIconUrl={serverIconUrl}
|
|
isMobile={isMobile}
|
|
/>
|
|
)}
|
|
{showMainContent && renderMainContent()}
|
|
{showPiP && <FloatingStreamPiP onGoBackToStream={handleGoBackToStream} />}
|
|
{showSearchDropdown && !isMobile && (
|
|
<SearchDropdown
|
|
visible={showSearchDropdown}
|
|
searchText={searchQuery}
|
|
channels={channels}
|
|
members={allMembers}
|
|
searchHistory={searchHistory}
|
|
onSelectFilter={handleSelectFilter}
|
|
onSelectHistoryItem={handleSelectHistoryItem}
|
|
onClearHistory={handleClearHistory}
|
|
onClearHistoryItem={handleClearHistoryItem}
|
|
anchorRef={searchInputRef}
|
|
onClose={handleCloseSearchDropdown}
|
|
/>
|
|
)}
|
|
<ToastContainer />
|
|
</div>
|
|
</PresenceProvider>
|
|
);
|
|
};
|
|
|
|
export default Chat;
|