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
All checks were successful
Build and Release / build-and-release (push) Successful in 13m49s
This commit is contained in:
@@ -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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user