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

@@ -9,3 +9,10 @@
- Can we add a way to tell the user they are connecting to voice. Like show them its connecting so the user knows something is happening instead of them clicking on the voice stage again and again.
- Add photo / video albums like Commit https://commet.chat/
<!-- When we call somone in a private dm call can we play the default_call_sound.mp3 to the person reciving
the call for about 30 seconds. After the 30 seconds we stop ringing. Also it shows "Moyettes is in a call" with a join call button but its to the left side and pushes chat. Can we make it take up the voice stage div and show the user that is calling and 2 buttons. One to join call and one to reject the call.
-->
When i switch from a private dm to the server i hear a ping notification even though i have no notifications. Also when i switch from a text channel to another text channel a certain text channel is the one thats triggering it and i dont know why. Its only for one user that its doing it.

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;
}
@@ -3782,3 +3797,87 @@ 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>
)}