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:
BIN
packages/shared/src/assets/sounds/default_call_sound.mp3
Normal file
BIN
packages/shared/src/assets/sounds/default_call_sound.mp3
Normal file
Binary file not shown.
28
packages/shared/src/components/IncomingCallUI.jsx
Normal file
28
packages/shared/src/components/IncomingCallUI.jsx
Normal 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;
|
||||
@@ -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 = () => {};
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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