feat: Implement incoming call UI with sound and integrate core chat and voice components.
All checks were successful
Build and Release / build-and-release (push) Successful in 13m49s

This commit is contained in:
Bryan1029384756
2026-02-16 14:39:44 -06:00
parent 5ca0eeb6a4
commit 6693ccf4c0
6 changed files with 231 additions and 17 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
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';
@@ -16,6 +16,9 @@ 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;
@@ -211,11 +214,66 @@ const Chat = () => {
? 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');
@@ -246,6 +304,9 @@ const Chat = () => {
// 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();
@@ -417,6 +478,7 @@ const Chat = () => {
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
@@ -431,10 +493,20 @@ const Chat = () => {
isDMCallActive={dmCallActive}
{...searchProps}
/>
<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>
<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>
)}