Files
backspace/packages/web/src/components/voice/IncomingCallModal.tsx
T
Jannis Braun c037803c5a fix: acceptor sets activeDmCall in click handler, not server response
Bug A: handleAccept relied on dm_call_accepted server response to set
activeDmCall. But connectFn's async AudioContext resume yields to the
event loop, dm_call_accepted arrives during the yield, finds
isLiveKitConnected=false (connectFn just reset it), and skips
setActiveDmCall. The acceptor connects to LiveKit but the UI never
shows the call. Fix: set activeDmCall and clear incomingCall
directly in the click handler.

Bug B: ready handler no longer sets activeDmCall for active calls.
On refresh/restart the client has no LiveKit connection — showing
"Connecting..." with no connection is broken. The call exists on
the server but this client session is disconnected.
2026-04-08 14:23:41 +02:00

138 lines
6.6 KiB
TypeScript

import React, { useEffect, useRef } from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore';
import { wsSend } from '../../hooks/useWebSocket';
import { parseFederatedUsername } from '../../utils/identity';
import { Avatar } from '../ui/Avatar';
export function IncomingCallModal() {
const incomingCall = useVoiceStore((s) => s.incomingCall);
const setIncomingCall = useVoiceStore((s) => s.setIncomingCall);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Auto-dismiss after 30 seconds
useEffect(() => {
if (incomingCall) {
timerRef.current = setTimeout(() => {
// Auto-reject after timeout
const { callOrigin, federatedCallId } = useVoiceStore.getState();
const origin = callOrigin || (incomingCall.dmChannelId ? getChannelOrigin(incomingCall.dmChannelId) : undefined);
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId, federatedCallId }, origin);
setIncomingCall(null);
}, 30000);
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [incomingCall, setIncomingCall]);
const dmChannels = useSpaceStore((s) => s.dmChannels);
if (!incomingCall) return null;
// Look up the caller in DM channel members for homeUserId
const dmChannel = dmChannels.find(d => d.id === incomingCall.dmChannelId);
const callerMember = dmChannel?.members.find(m => m.id === incomingCall.callerId);
const callerAvatarId = callerMember?.homeUserId ?? incomingCall.callerId;
const { baseName: callerBaseName } = parseFederatedUsername(incomingCall.callerName);
const handleAccept = () => {
if (timerRef.current) clearTimeout(timerRef.current);
const dmChannelId = incomingCall.dmChannelId;
const { callOrigin, federatedCallId, setActiveDmCall, connectFn } = useVoiceStore.getState();
const origin = callOrigin || (dmChannelId ? getChannelOrigin(dmChannelId) : undefined);
const callDmId = dmChannelId || federatedCallId!;
// Immediately transition to active call state — don't wait for server response.
// The dm_call_accepted event races with connectFn's async AudioContext resume,
// causing isLiveKitConnected to be false when it arrives → activeDmCall never set.
setIncomingCall(null);
setActiveDmCall({ dmChannelId: callDmId });
wsSend({ type: 'dm_call_accept', dmChannelId, federatedCallId }, origin);
// Connect directly within gesture context (required for iOS audio permission)
if (connectFn) connectFn(callDmId, true);
};
const handleDecline = () => {
if (timerRef.current) clearTimeout(timerRef.current);
const { callOrigin, federatedCallId } = useVoiceStore.getState();
const dmChannelId = incomingCall.dmChannelId;
const origin = callOrigin || (dmChannelId ? getChannelOrigin(dmChannelId) : undefined);
wsSend({ type: 'dm_call_reject', dmChannelId, federatedCallId }, origin);
setIncomingCall(null);
};
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
{/* Backdrop */}
<div className="absolute inset-0 bg-black/50" />
{/* Call card */}
<div className="relative glass-modal call-refraction rounded-lg w-[340px] overflow-hidden animate-fade-in animate-slide-up">
{/* Liquid ripple orbs — soft radial gradients with blur */}
<div className="absolute inset-0 overflow-hidden pointer-events-none">
{[0, 1.3, 2.6].map((delay, i) => (
<div
key={i}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[180px] h-[180px] rounded-full blur-xl animate-call-ripple"
style={{
animationDelay: `${delay}s`,
background: 'radial-gradient(circle, rgba(134,239,172,0.18) 0%, rgba(134,239,172,0.06) 35%, rgba(134,239,172,0.02) 55%, transparent 70%)',
}}
/>
))}
</div>
{/* Content */}
<div className="relative z-[2] p-8 flex flex-col items-center gap-4">
{/* Caller avatar */}
<div className="rounded-full animate-call-glow">
<Avatar
src={callerMember?.avatar}
avatarColor={callerMember?.avatarColor}
userId={callerAvatarId}
name={callerBaseName}
size={80}
/>
</div>
{/* Caller info */}
<div className="text-center">
<h3 className="text-[20px] font-bold text-txt-primary">{callerBaseName}</h3>
<p className="text-[14px] text-txt-tertiary mt-1">Incoming Voice Call...</p>
</div>
{/* Action buttons */}
<div className="flex items-center gap-6 mt-2">
{/* Decline */}
<button
onClick={handleDecline}
className="w-14 h-14 rounded-full bg-accent-rose/20 border border-accent-rose/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-accent-rose/35 group"
title="Decline"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-accent-rose group-hover:scale-110 transition-transform">
<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.28-.28 0-.53-.11-.71-.29L.29 13.08c-.18-.17-.29-.42-.29-.7 0-.28.11-.53.29-.71C3.34 8.78 7.46 7 12 7s8.66 1.78 11.71 4.67c.18.18.29.43.29.71 0 .28-.11.53-.29.71l-2.48 2.48c-.18.18-.43.29-.71.29-.27 0-.52-.11-.7-.28-.79-.74-1.69-1.36-2.67-1.85-.33-.16-.56-.5-.56-.9v-3.1C15.15 9.25 13.6 9 12 9z" />
</svg>
</button>
{/* Accept */}
<button
onClick={handleAccept}
className="w-14 h-14 rounded-full bg-status-online/20 border border-status-online/30 backdrop-blur-sm flex items-center justify-center transition-all duration-200 hover:bg-status-online/35 animate-call-button-glow group"
title="Accept"
>
<svg width="28" height="28" viewBox="0 0 24 24" fill="currentColor" className="text-status-online group-hover:scale-110 transition-transform">
<path d="M6.62 10.79c1.44 2.83 3.76 5.14 6.59 6.59l2.2-2.2c.27-.27.67-.36 1.02-.24 1.12.37 2.33.57 3.57.57.55 0 1 .45 1 1V20c0 .55-.45 1-1 1-9.39 0-17-7.61-17-17 0-.55.45-1 1-1h3.5c.55 0 1 .45 1 1 0 1.25.2 2.45.57 3.57.11.35.03.74-.25 1.02l-2.2 2.2z" />
</svg>
</button>
</div>
</div>
</div>
</div>
);
}