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

@@ -0,0 +1,28 @@
import React from 'react';
import Avatar from './Avatar';
const IncomingCallUI = ({ callerUsername, callerAvatarUrl, onJoin, onReject }) => {
return (
<div className="incoming-call-ui">
<div className="incoming-call-avatar-ring">
<Avatar username={callerUsername} avatarUrl={callerAvatarUrl} size={80} />
</div>
<div className="incoming-call-username">{callerUsername}</div>
<div className="incoming-call-subtitle">Incoming call...</div>
<div className="incoming-call-buttons">
<button className="incoming-call-btn join" onClick={onJoin} title="Join Call">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M6.62 10.79a15.05 15.05 0 0 0 6.59 6.59l2.2-2.2a1 1 0 0 1 1.01-.24c1.12.37 2.33.57 3.58.57a1 1 0 0 1 1 1V20a1 1 0 0 1-1 1A17 17 0 0 1 3 4a1 1 0 0 1 1-1h3.5a1 1 0 0 1 1 1c0 1.25.2 2.46.57 3.58a1 1 0 0 1-.25 1.01l-2.2 2.2z"/>
</svg>
</button>
<button className="incoming-call-btn reject" onClick={onReject} title="Reject">
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 9c-1.6 0-3.15.25-4.6.72v3.1c0 .39-.23.74-.56.9-.98.49-1.87 1.12-2.66 1.85-.18.18-.43.28-.7.28a1 1 0 0 1-.71-.3L.29 13.08a1 1 0 0 1 0-1.41C3.57 8.55 7.53 7 12 7s8.43 1.55 11.71 4.67a1 1 0 0 1 0 1.41l-2.48 2.48a1 1 0 0 1-.7.29c-.27 0-.52-.11-.7-.28a11.27 11.27 0 0 0-2.67-1.85.99.99 0 0 1-.56-.9v-3.1A15.9 15.9 0 0 0 12 9z"/>
</svg>
</button>
</div>
</div>
);
};
export default IncomingCallUI;

View File

@@ -798,14 +798,17 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
...channels.map(c => c._id),
...dmChannels.map(dm => dm.channel_id)
], [channels, dmChannels]);
const allReadStates = useQuery(
const rawAllReadStates = useQuery(
api.readState.getAllReadStates,
userId ? { userId } : "skip"
) || [];
const latestTimestamps = useQuery(
);
const rawLatestTimestamps = useQuery(
api.readState.getLatestMessageTimestamps,
channelIds.length > 0 ? { channelIds } : "skip"
) || [];
);
const allReadStates = rawAllReadStates || [];
const latestTimestamps = rawLatestTimestamps || [];
const unreadQueriesLoaded = rawAllReadStates !== undefined && rawLatestTimestamps !== undefined;
const unreadChannels = React.useMemo(() => {
const set = new Set();
@@ -835,8 +838,13 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
const prevUnreadDMsRef = useRef(null);
useEffect(() => {
if (!unreadQueriesLoaded) return;
const currentIds = new Set(
dmChannels.filter(dm => unreadChannels.has(dm.channel_id)).map(dm => dm.channel_id)
dmChannels.filter(dm =>
unreadChannels.has(dm.channel_id) &&
!(view === 'me' && activeDMChannel?.channel_id === dm.channel_id)
).map(dm => dm.channel_id)
);
if (prevUnreadDMsRef.current === null) {
@@ -856,7 +864,7 @@ const Sidebar = ({ channels, categories, activeChannel, onSelectChannel, usernam
}
prevUnreadDMsRef.current = currentIds;
}, [dmChannels, unreadChannels, isReceivingScreenShareAudio]);
}, [dmChannels, unreadChannels, unreadQueriesLoaded, view, activeDMChannel, isReceivingScreenShareAudio]);
const onRenameChannel = () => {};

View File

@@ -3757,14 +3757,29 @@ img.search-dropdown-avatar {
opacity: 0.3;
}
.dm-call-banner {
.dm-call-idle-stage {
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 8px 16px;
background-color: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary);
justify-content: center;
background-color: var(--bg-tertiary);
padding: 32px 16px;
gap: 8px;
min-height: 200px;
height: 45%;
max-height: 80%;
border-bottom: 2px solid var(--bg-tertiary);
}
.dm-call-idle-username {
color: var(--text-normal);
font-size: 18px;
font-weight: 600;
margin-top: 8px;
}
.dm-call-idle-status {
color: var(--text-muted);
font-size: 14px;
}
@@ -3781,4 +3796,88 @@ img.search-dropdown-avatar {
.dm-call-join-btn:hover {
background-color: #2d7d46;
}
/* Incoming call UI */
.incoming-call-ui {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: var(--bg-tertiary);
padding: 32px 16px;
gap: 12px;
flex: 1;
min-height: 260px;
}
.incoming-call-avatar-ring {
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
}
.incoming-call-avatar-ring::before {
content: '';
position: absolute;
width: 96px;
height: 96px;
border-radius: 50%;
border: 3px solid #3ba55c;
animation: call-ring-pulse 1.5s ease-out infinite;
}
@keyframes call-ring-pulse {
0% {
transform: scale(1);
opacity: 0.8;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
.incoming-call-username {
color: var(--text-normal);
font-size: 20px;
font-weight: 600;
}
.incoming-call-subtitle {
color: var(--text-muted);
font-size: 14px;
}
.incoming-call-buttons {
display: flex;
gap: 24px;
margin-top: 16px;
}
.incoming-call-btn {
width: 56px;
height: 56px;
border-radius: 50%;
border: none;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: white;
transition: filter 0.15s;
}
.incoming-call-btn:hover {
filter: brightness(1.15);
}
.incoming-call-btn.join {
background-color: #3ba55c;
}
.incoming-call-btn.reject {
background-color: #ed4245;
}

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>
)}