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:
9
TODO.md
9
TODO.md
@@ -8,4 +8,11 @@
|
||||
|
||||
- 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/
|
||||
- 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.
|
||||
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