feat: add federatedCallId and callOrigin to client call routing

Enable federated DM calls to route accept/reject/end through the correct
WebSocket connection using callOrigin, and include federatedCallId in all
dm_call payloads for server-side FederatedCallEntry lookup.
This commit is contained in:
Jannis Braun
2026-04-08 03:26:48 +02:00
parent 0c57f9491f
commit ba7b975bc8
10 changed files with 73 additions and 31 deletions
+4 -4
View File
@@ -377,9 +377,9 @@ export type ClientEvent =
| { type: 'channel_ack'; channelId: string; messageId: string } | { type: 'channel_ack'; channelId: string; messageId: string }
| { type: 'mark_unread'; channelId: string; messageId: string } | { type: 'mark_unread'; channelId: string; messageId: string }
| { type: 'dm_call_start'; dmChannelId: string } | { type: 'dm_call_start'; dmChannelId: string }
| { type: 'dm_call_accept'; dmChannelId: string } | { type: 'dm_call_accept'; dmChannelId: string | null; federatedCallId?: string | null }
| { type: 'dm_call_reject'; dmChannelId: string } | { type: 'dm_call_reject'; dmChannelId: string | null; federatedCallId?: string | null }
| { type: 'dm_call_end'; dmChannelId: string } | { type: 'dm_call_end'; dmChannelId: string | null; federatedCallId?: string | null }
| { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'voice_status'; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
| { type: 'voice_space_mute'; userId: string; muted: boolean } | { type: 'voice_space_mute'; userId: string; muted: boolean }
| { type: 'voice_space_deafen'; userId: string; deafened: boolean } | { type: 'voice_space_deafen'; userId: string; deafened: boolean }
@@ -410,7 +410,7 @@ export type ServerEvent =
| { type: 'friend_request_received'; request: FriendRequest } | { type: 'friend_request_received'; request: FriendRequest }
| { type: 'friend_request_accepted'; friend: Friend; requestId: string } | { type: 'friend_request_accepted'; friend: Friend; requestId: string }
| { type: 'dm_call_incoming'; dmChannelId: string | null; federatedCallId?: string; callerId: string; callerName: string; livekitUrl?: string; livekitToken?: string; callOrigin?: string } | { type: 'dm_call_incoming'; dmChannelId: string | null; federatedCallId?: string; callerId: string; callerName: string; livekitUrl?: string; livekitToken?: string; callOrigin?: string }
| { type: 'dm_call_accepted'; dmChannelId: string } | { type: 'dm_call_accepted'; dmChannelId: string | null; federatedCallId?: string }
| { type: 'dm_call_rejected'; dmChannelId: string } | { type: 'dm_call_rejected'; dmChannelId: string }
| { type: 'dm_call_ended'; dmChannelId: string } | { type: 'dm_call_ended'; dmChannelId: string }
| { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean } | { type: 'voice_status_update'; userId: string; channelId: string; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; isScreenSharing: boolean }
@@ -86,7 +86,7 @@ export function NotificationController() {
// DM call notification // DM call notification
useEffect(() => { useEffect(() => {
let prevIncoming: { dmChannelId: string; callerId: string; callerName: string } | null = null; let prevIncoming: { dmChannelId: string | null; callerId: string; callerName: string } | null = null;
const unsubscribe = useVoiceStore.subscribe((state) => { const unsubscribe = useVoiceStore.subscribe((state) => {
if (state.incomingCall && !prevIncoming && !windowFocused.current) { if (state.incomingCall && !prevIncoming && !windowFocused.current) {
@@ -102,7 +102,9 @@ export function MainContent() {
const handleCancelCall = () => { const handleCancelCall = () => {
if (!currentChannelId) return; if (!currentChannelId) return;
useVoiceStore.getState().setOutgoingCall(null); useVoiceStore.getState().setOutgoingCall(null);
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId }, getChannelOrigin(currentChannelId)); const { federatedCallId, callOrigin } = useVoiceStore.getState();
const origin = callOrigin || getChannelOrigin(currentChannelId);
wsSend({ type: 'dm_call_end', dmChannelId: currentChannelId, federatedCallId }, origin);
}; };
if (isInDmCall) { if (isInDmCall) {
@@ -98,9 +98,10 @@ export function MobileVoiceFullScreen() {
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
const { activeDmCall, disconnectFn } = useVoiceStore.getState(); const { activeDmCall, disconnectFn, federatedCallId, callOrigin } = useVoiceStore.getState();
if (activeDmCall) { if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); const origin = callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId }, origin);
useVoiceStore.getState().setActiveDmCall(null); useVoiceStore.getState().setActiveDmCall(null);
} else if (currentVoiceChannelId) { } else if (currentVoiceChannelId) {
wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId)); wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId));
@@ -95,9 +95,10 @@ export function MobileVoiceMiniBar() {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
const { activeDmCall, disconnectFn } = useVoiceStore.getState(); const { activeDmCall, disconnectFn, federatedCallId, callOrigin } = useVoiceStore.getState();
if (activeDmCall) { if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); const origin = callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId }, origin);
useVoiceStore.getState().setActiveDmCall(null); useVoiceStore.getState().setActiveDmCall(null);
} else if (currentVoiceChannelId) { } else if (currentVoiceChannelId) {
wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId)); wsSend({ type: 'voice_leave' }, getChannelOrigin(currentVoiceChannelId));
@@ -15,7 +15,9 @@ export function IncomingCallModal() {
if (incomingCall) { if (incomingCall) {
timerRef.current = setTimeout(() => { timerRef.current = setTimeout(() => {
// Auto-reject after timeout // Auto-reject after timeout
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId)); 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); setIncomingCall(null);
}, 30000); }, 30000);
} }
@@ -40,16 +42,20 @@ export function IncomingCallModal() {
const handleAccept = () => { const handleAccept = () => {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
const dmChannelId = incomingCall.dmChannelId; const dmChannelId = incomingCall.dmChannelId;
const origin = getChannelOrigin(dmChannelId); const { callOrigin, federatedCallId } = useVoiceStore.getState();
wsSend({ type: 'dm_call_accept', dmChannelId }, origin); const origin = callOrigin || (dmChannelId ? getChannelOrigin(dmChannelId) : undefined);
wsSend({ type: 'dm_call_accept', dmChannelId, federatedCallId }, origin);
// Connect directly within gesture context (required for iOS audio permission) // Connect directly within gesture context (required for iOS audio permission)
const connectFn = useVoiceStore.getState().connectFn; const connectFn = useVoiceStore.getState().connectFn;
if (connectFn) connectFn(dmChannelId, true); if (connectFn) connectFn(dmChannelId || federatedCallId!, true);
}; };
const handleDecline = () => { const handleDecline = () => {
if (timerRef.current) clearTimeout(timerRef.current); if (timerRef.current) clearTimeout(timerRef.current);
wsSend({ type: 'dm_call_reject', dmChannelId: incomingCall.dmChannelId }, getChannelOrigin(incomingCall.dmChannelId)); 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); setIncomingCall(null);
}; };
@@ -75,9 +75,10 @@ export function VoiceControls() {
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
const { activeDmCall, disconnectFn } = useVoiceStore.getState(); const { activeDmCall, disconnectFn, federatedCallId, callOrigin } = useVoiceStore.getState();
if (activeDmCall) { if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); const origin = callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId }, origin);
useVoiceStore.getState().setActiveDmCall(null); useVoiceStore.getState().setActiveDmCall(null);
} else { } else {
wsSend({ type: 'voice_leave' }, voiceOrigin); wsSend({ type: 'voice_leave' }, voiceOrigin);
+23 -9
View File
@@ -314,20 +314,24 @@ function handleEvent(origin: string, event: ServerEvent): void {
// Restore DM call state from server (all origins — federated DMs live on remote instances) // Restore DM call state from server (all origins — federated DMs live on remote instances)
{ {
const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall, connectFn, disconnectFn, setFederatedCallData } = useVoiceStore.getState(); const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall, connectFn, disconnectFn, setFederatedCallData, setFederatedCallId } = useVoiceStore.getState();
const myId = event.user.id; const myId = event.user.id;
if (event.activeCalls && event.activeCalls.length > 0) { if (event.activeCalls && event.activeCalls.length > 0) {
for (const call of event.activeCalls) { for (const call of event.activeCalls) {
// For federated calls, check membership via token presence (participants may be empty) // For federated calls, check membership via token presence (participants may be empty)
const isParticipant = call.participants.includes(myId) || !!call.livekitToken; const isParticipant = call.participants.includes(myId) || !!call.livekitToken;
if (call.state === 'active' && isParticipant) { if (call.state === 'active' && isParticipant) {
setActiveDmCall({ dmChannelId: call.dmChannelId }); const callDmId = call.dmChannelId || call.federatedCallId || '';
setActiveDmCall({ dmChannelId: callDmId });
// Store federated call data if present (server already filtered to this user's token) // Store federated call data if present (server already filtered to this user's token)
if (call.livekitUrl && call.livekitToken) { if (call.livekitUrl && call.livekitToken) {
setFederatedCallData(call.livekitToken, call.livekitUrl); setFederatedCallData(call.livekitToken, call.livekitUrl);
} }
if (connectFn) { if (call.federatedCallId) {
connectFn(call.dmChannelId, true).catch((err) => { setFederatedCallId(call.federatedCallId);
}
if (connectFn && callDmId) {
connectFn(callDmId, true).catch((err) => {
console.error('[WS] DM call reconnect failed:', err); console.error('[WS] DM call reconnect failed:', err);
}); });
} }
@@ -344,6 +348,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (call.livekitUrl && call.livekitToken) { if (call.livekitUrl && call.livekitToken) {
setFederatedCallData(call.livekitToken, call.livekitUrl); setFederatedCallData(call.livekitToken, call.livekitUrl);
} }
if (call.federatedCallId) {
setFederatedCallId(call.federatedCallId);
}
} }
} }
} else { } else {
@@ -770,9 +777,9 @@ function handleEvent(origin: string, event: ServerEvent): void {
// ─── DM call events (all origins) ────────────────────────────────────── // ─── DM call events (all origins) ──────────────────────────────────────
case 'dm_call_incoming': { case 'dm_call_incoming': {
const { setIncomingCall, setFederatedCallData } = useVoiceStore.getState(); const { setIncomingCall, setFederatedCallData, setFederatedCallId, setCallOrigin } = useVoiceStore.getState();
setIncomingCall({ setIncomingCall({
dmChannelId: event.dmChannelId, dmChannelId: event.dmChannelId ?? null,
callerId: event.callerId, callerId: event.callerId,
callerName: event.callerName, callerName: event.callerName,
}); });
@@ -780,6 +787,12 @@ function handleEvent(origin: string, event: ServerEvent): void {
if (event.livekitUrl && event.livekitToken) { if (event.livekitUrl && event.livekitToken) {
setFederatedCallData(event.livekitToken, event.livekitUrl); setFederatedCallData(event.livekitToken, event.livekitUrl);
} }
if (event.federatedCallId) {
setFederatedCallId(event.federatedCallId);
}
if (event.callOrigin) {
setCallOrigin(event.callOrigin);
}
break; break;
} }
@@ -787,10 +800,11 @@ function handleEvent(origin: string, event: ServerEvent): void {
const { setIncomingCall, setOutgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState(); const { setIncomingCall, setOutgoingCall, setActiveDmCall, connectFn, isLiveKitConnected } = useVoiceStore.getState();
setIncomingCall(null); setIncomingCall(null);
setOutgoingCall(null); setOutgoingCall(null);
setActiveDmCall({ dmChannelId: event.dmChannelId }); const callDmId = event.dmChannelId || event.federatedCallId || '';
setActiveDmCall({ dmChannelId: callDmId });
// Only connect if not already connected (federated acceptor connects immediately in handleAccept) // Only connect if not already connected (federated acceptor connects immediately in handleAccept)
if (connectFn && !isLiveKitConnected) { if (connectFn && !isLiveKitConnected && callDmId) {
connectFn(event.dmChannelId, true).catch((err) => { connectFn(callDmId, true).catch((err: unknown) => {
console.error('[WS] DM call connect failed:', err); console.error('[WS] DM call connect failed:', err);
}); });
} }
+19 -3
View File
@@ -61,16 +61,20 @@ interface VoiceState {
setStreamAttenuationEnabled: (enabled: boolean) => void; setStreamAttenuationEnabled: (enabled: boolean) => void;
setStreamAttenuationStrength: (strength: number) => void; setStreamAttenuationStrength: (strength: number) => void;
// DM call state // DM call state
incomingCall: { dmChannelId: string; callerId: string; callerName: string } | null; incomingCall: { dmChannelId: string | null; callerId: string; callerName: string } | null;
outgoingCall: { dmChannelId: string } | null; outgoingCall: { dmChannelId: string } | null;
activeDmCall: { dmChannelId: string } | null; activeDmCall: { dmChannelId: string } | null;
setIncomingCall: (call: { dmChannelId: string; callerId: string; callerName: string } | null) => void; setIncomingCall: (call: { dmChannelId: string | null; callerId: string; callerName: string } | null) => void;
setOutgoingCall: (call: { dmChannelId: string } | null) => void; setOutgoingCall: (call: { dmChannelId: string } | null) => void;
setActiveDmCall: (call: { dmChannelId: string } | null) => void; setActiveDmCall: (call: { dmChannelId: string } | null) => void;
federatedCallToken: string | null; federatedCallToken: string | null;
federatedCallUrl: string | null; federatedCallUrl: string | null;
federatedCallId: string | null;
callOrigin: string | null;
setFederatedCallData: (token: string, url: string) => void; setFederatedCallData: (token: string, url: string) => void;
clearFederatedCallData: () => void; clearFederatedCallData: () => void;
setFederatedCallId: (id: string | null) => void;
setCallOrigin: (origin: string | null) => void;
setVoiceUsers: (channelId: string, userIds: string[]) => void; setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void; addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void; removeVoiceUser: (channelId: string, userId: string) => void;
@@ -246,12 +250,16 @@ export const useVoiceStore = create<VoiceState>()(
activeDmCall: null, activeDmCall: null,
federatedCallToken: null, federatedCallToken: null,
federatedCallUrl: null, federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
setIncomingCall: (call) => set({ incomingCall: call }), setIncomingCall: (call) => set({ incomingCall: call }),
setOutgoingCall: (call) => set({ outgoingCall: call }), setOutgoingCall: (call) => set({ outgoingCall: call }),
setActiveDmCall: (call) => set({ activeDmCall: call }), setActiveDmCall: (call) => set({ activeDmCall: call }),
setFederatedCallData: (token, url) => set({ federatedCallToken: token, federatedCallUrl: url }), setFederatedCallData: (token, url) => set({ federatedCallToken: token, federatedCallUrl: url }),
clearFederatedCallData: () => set({ federatedCallToken: null, federatedCallUrl: null }), clearFederatedCallData: () => set({ federatedCallToken: null, federatedCallUrl: null, federatedCallId: null, callOrigin: null }),
setFederatedCallId: (id) => set({ federatedCallId: id }),
setCallOrigin: (origin) => set({ callOrigin: origin }),
setVoiceUsers: (channelId, userIds) => { setVoiceUsers: (channelId, userIds) => {
set((state) => { set((state) => {
@@ -426,6 +434,8 @@ export const useVoiceStore = create<VoiceState>()(
activeDmCall: null, activeDmCall: null,
federatedCallToken: null, federatedCallToken: null,
federatedCallUrl: null, federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
// Per-session media state // Per-session media state
isCameraOn: false, isCameraOn: false,
isScreenSharing: false, isScreenSharing: false,
@@ -484,6 +494,8 @@ export const useVoiceStore = create<VoiceState>()(
outgoingCall: null, outgoingCall: null,
federatedCallToken: null, federatedCallToken: null,
federatedCallUrl: null, federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
deafenedUserIds: new Set(), deafenedUserIds: new Set(),
participantMutes: new Map(), participantMutes: new Map(),
streamVolumes: new Map(), streamVolumes: new Map(),
@@ -521,6 +533,8 @@ export const useVoiceStore = create<VoiceState>()(
outgoingCall: null, outgoingCall: null,
federatedCallToken: null, federatedCallToken: null,
federatedCallUrl: null, federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
deafenedUserIds: new Set(), deafenedUserIds: new Set(),
participantMutes: new Map(), participantMutes: new Map(),
streamVolumes: new Map(), streamVolumes: new Map(),
@@ -555,6 +569,8 @@ export const useVoiceStore = create<VoiceState>()(
activeDmCall: null, activeDmCall: null,
federatedCallToken: null, federatedCallToken: null,
federatedCallUrl: null, federatedCallUrl: null,
federatedCallId: null,
callOrigin: null,
deafenedUserIds: new Set(), deafenedUserIds: new Set(),
voiceUserStates: new Map(), voiceUserStates: new Map(),
streamVolumes: new Map(), streamVolumes: new Map(),
+3 -2
View File
@@ -88,9 +88,10 @@ export function handleDisconnectAction(): void {
const { activeDmCall, currentVoiceChannelId, disconnectFn } = voice; const { activeDmCall, currentVoiceChannelId, disconnectFn } = voice;
if (activeDmCall) { if (activeDmCall) {
const origin = voice.callOrigin || getChannelOrigin(activeDmCall.dmChannelId);
wsSend( wsSend(
{ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, { type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId, federatedCallId: voice.federatedCallId },
getChannelOrigin(activeDmCall.dmChannelId) origin
); );
voice.setActiveDmCall(null); voice.setActiveDmCall(null);
} else if (currentVoiceChannelId) { } else if (currentVoiceChannelId) {