Add private calls
All checks were successful
Build and Release / build-and-release (push) Successful in 11m59s

This commit is contained in:
Bryan1029384756
2026-02-16 13:46:26 -06:00
parent 73b9da2003
commit 5ca0eeb6a4
13 changed files with 286 additions and 57 deletions

View File

@@ -49,7 +49,7 @@ const Chat = () => {
const convex = useConvex();
const { toasts, addToast, removeToast, ToastContainer } = useToasts();
const prevDmChannelsRef = useRef(null);
const { toggleMute } = useVoice();
const { toggleMute, connectToVoice, disconnectVoice, activeChannelId: voiceActiveChannelId, voiceStates, room } = useVoice();
// Keyboard shortcuts
useEffect(() => {
@@ -197,15 +197,93 @@ const Chat = () => {
}, []);
const activeChannelObj = channels.find(c => c._id === activeChannel);
const { room, voiceStates, activeChannelId: voiceActiveChannelId, watchingStreamOf } = useVoice();
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);
useEffect(() => {
if (!isInDMCall) setDmCallExpanded(false);
}, [isInDMCall]);
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;
// 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 isViewingVoiceStage = view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId;
const isViewingDMCallStage = isDMView && isInDMCall;
const isViewingVoiceStage = (view === 'server' && activeChannelObj?.type === 'voice' && activeChannel === voiceActiveChannelId) || isViewingDMCallStage;
const showPiP = watchingStreamOf !== null && !isViewingVoiceStage;
const handleGoBackToStream = useCallback(() => {
@@ -349,33 +427,61 @@ const Chat = () => {
onTogglePinned={() => setShowPinned(p => !p)}
isMobile={isMobile}
onMobileBack={handleMobileBack}
onStartCall={handleStartDMCall}
isDMCallActive={dmCallActive}
{...searchProps}
/>
<div className="chat-content">
<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)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
<div className="chat-content" style={isInDMCall ? { flexDirection: 'column' } : undefined}>
{dmCallActive && !isInDMCall && (
<div className="dm-call-banner">
<span>{activeDMChannel.other_username} is in a call</span>
<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)}
/>
<SearchPanel
visible={showSearchResults}
onClose={handleCloseSearchResults}
channels={channels}
isDM={true}
dmChannelId={activeDMChannel.channel_id}
onJumpToMessage={handleJumpToMessage}
query={searchQuery}
sortOrder={searchSortOrder}
onSortChange={setSearchSortOrder}
/>
</div>
)}
</div>
</div>
);