feat: gesture-aware voice connection, remove AppLayout auto-connect

Replace the useEffect-based auto-connect pattern in AppLayout with
direct connect/disconnect calls from user gesture contexts. This is
required for iOS (AudioContext.resume + getUserMedia must happen in
a gesture handler) and aligns with tightening autoplay policies on
desktop browsers.

Architecture:
- voiceStore gains connectFn/disconnectFn refs, registered by AppLayout
  from the single useLiveKit() instance.
- All voice join paths (ChannelSidebar, MobileSpacesScreen, MainContent,
  voice_moved WS handler) pass connectFn to joinVoiceChannel().
- All disconnect paths (VoiceControls, voiceActions, MobileVoiceFullScreen,
  MobileVoiceMiniBar, dm_call_ended/rejected WS handlers, ready handler)
  call disconnectFn() directly.
- dm_call_accepted WS handler calls connectFn() to initiate the DM call
  LiveKit connection.
- The 55-line auto-connect useEffect and lastAttemptedRef are removed.
This commit is contained in:
Jannis Braun
2026-03-23 21:06:19 +01:00
parent 8aa66816d8
commit 241451ca18
11 changed files with 80 additions and 81 deletions
@@ -108,18 +108,24 @@ export function AppLayout() {
const channels = useSpaceStore((s) => s.channels); const channels = useSpaceStore((s) => s.channels);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
const { const {
connect: connectVoice, connect: connectVoice,
disconnect: disconnectVoice, disconnect: disconnectVoice,
isConnected: isVoiceConnected,
isConnecting: isVoiceConnecting,
connectedChannelId,
} = useLiveKit(); } = useLiveKit();
// Register connect/disconnect refs in voiceStore so click handlers can access them
// without calling useLiveKit() (which would create duplicate Room instances).
useEffect(() => {
useVoiceStore.getState().setConnectFn(connectVoice);
useVoiceStore.getState().setDisconnectFn(disconnectVoice);
return () => {
useVoiceStore.getState().setConnectFn(null);
useVoiceStore.getState().setDisconnectFn(null);
};
}, [connectVoice, disconnectVoice]);
// Initialize WebSocket // Initialize WebSocket
const { isConnected: isWsConnected } = useWebSocket(); useWebSocket();
// Federation toast notifications for remote instance connection state changes // Federation toast notifications for remote instance connection state changes
useFederationToasts(); useFederationToasts();
@@ -136,67 +142,6 @@ export function AppLayout() {
return () => teardownActivityBridge(); return () => teardownActivityBridge();
}, []); }, []);
// Track the last channel we attempted to connect to, to prevent effect loops
const lastAttemptedRef = React.useRef<string | null>(null);
// Manage voice connection
useEffect(() => {
if (isLoading || !user || !isWsConnected) return;
const manageConnection = async () => {
// Determine what we SHOULD be connected to
const targetChannelId = activeDmCall
? `dm-${activeDmCall.dmChannelId}`
: currentVoiceChannelId;
// 1. If we have a target
if (targetChannelId) {
// If we're not connected to the RIGHT place, trigger connect.
// We IGNORE isVoiceConnecting here to allow "interrupting" a connection
// or switching rooms immediately.
if (connectedChannelId !== targetChannelId) {
// Prevent spamming the same connection attempt if React re-renders
if (lastAttemptedRef.current === targetChannelId && isVoiceConnecting) {
return;
}
console.log(`[AppLayout] Switching/Connecting to: ${targetChannelId}`);
lastAttemptedRef.current = targetChannelId;
if (activeDmCall) {
await connectVoice(activeDmCall.dmChannelId, true);
} else {
await connectVoice(targetChannelId);
}
} else {
// We are connected to the right place. Reset ref.
lastAttemptedRef.current = null;
}
return;
}
// 2. No target — ensure disconnected
if (connectedChannelId !== null || isVoiceConnected || isVoiceConnecting) {
console.log('[AppLayout] Leaving voice (no target)');
lastAttemptedRef.current = null;
await disconnectVoice();
}
};
manageConnection();
}, [
currentVoiceChannelId,
activeDmCall,
connectedChannelId,
isVoiceConnected,
isVoiceConnecting,
isWsConnected,
isLoading,
user,
connectVoice,
disconnectVoice
]);
// Responsive detection // Responsive detection
useEffect(() => { useEffect(() => {
const checkMobile = () => setIsMobile(window.innerWidth < 768); const checkMobile = () => setIsMobile(window.innerWidth < 768);
@@ -403,7 +403,8 @@ export function ChannelSidebar() {
navigate(`/channels/${currentSpaceId}/${channelId}`); navigate(`/channels/${currentSpaceId}/${channelId}`);
return; return;
} }
joinVoiceChannel(channelId); const connectFn = useVoiceStore.getState().connectFn;
joinVoiceChannel(channelId, connectFn ?? undefined);
navigate(`/channels/${currentSpaceId}/${channelId}`); navigate(`/channels/${currentSpaceId}/${channelId}`);
}; };
@@ -274,7 +274,7 @@ export function MainContent() {
<p className="text-txt-tertiary text-[15px]">No one is currently in this voice channel.</p> <p className="text-txt-tertiary text-[15px]">No one is currently in this voice channel.</p>
</div> </div>
<button <button
onClick={() => joinVoiceChannel(currentChannelId)} onClick={() => joinVoiceChannel(currentChannelId, useVoiceStore.getState().connectFn ?? undefined)}
className="relative z-10 px-8 py-3 bg-accent-primary hover:bg-accent-primary-hover text-white font-semibold rounded-full transition-all text-[15px] shadow-[0_4px_20px_rgba(124,108,246,0.3)]" className="relative z-10 px-8 py-3 bg-accent-primary hover:bg-accent-primary-hover text-white font-semibold rounded-full transition-all text-[15px] shadow-[0_4px_20px_rgba(124,108,246,0.3)]"
> >
Join Voice Join Voice
@@ -194,8 +194,12 @@ export function MobileSpacesScreen() {
}); });
}; };
const handleVoiceJoin = useCallback((chId: string, _preMuted: boolean) => { const handleVoiceJoin = useCallback((chId: string, preMuted: boolean) => {
joinVoiceChannel(chId); if (preMuted) {
useVoiceStore.getState().setMuted(true);
}
const connectFn = useVoiceStore.getState().connectFn;
joinVoiceChannel(chId, connectFn ?? undefined);
setVoiceJoinChannelId(null); setVoiceJoinChannelId(null);
pushMobileScreen('voice'); pushMobileScreen('voice');
}, [pushMobileScreen]); }, [pushMobileScreen]);
@@ -97,6 +97,8 @@ export function MobileVoiceFullScreen() {
const handleDisconnect = () => { const handleDisconnect = () => {
leaveVoice(); leaveVoice();
const disconnectFn = useVoiceStore.getState().disconnectFn;
if (disconnectFn) disconnectFn();
popMobileScreen(); popMobileScreen();
}; };
@@ -92,7 +92,7 @@ export function MobileVoiceMiniBar() {
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); leaveVoice(); }} onClick={(e) => { e.stopPropagation(); leaveVoice(); const df = useVoiceStore.getState().disconnectFn; if (df) df(); }}
className="w-8 h-8 rounded-full flex items-center justify-center bg-accent-rose/20 text-accent-rose hover:bg-accent-rose/30 transition-colors" className="w-8 h-8 rounded-full flex items-center justify-center bg-accent-rose/20 text-accent-rose hover:bg-accent-rose/30 transition-colors"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
@@ -75,7 +75,7 @@ export function VoiceControls() {
}; };
const handleDisconnect = () => { const handleDisconnect = () => {
const { activeDmCall } = useVoiceStore.getState(); const { activeDmCall, disconnectFn } = useVoiceStore.getState();
if (activeDmCall) { if (activeDmCall) {
wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId)); wsSend({ type: 'dm_call_end', dmChannelId: activeDmCall.dmChannelId }, getChannelOrigin(activeDmCall.dmChannelId));
useVoiceStore.getState().setActiveDmCall(null); useVoiceStore.getState().setActiveDmCall(null);
@@ -83,6 +83,7 @@ export function VoiceControls() {
wsSend({ type: 'voice_leave' }, voiceOrigin); wsSend({ type: 'voice_leave' }, voiceOrigin);
useVoiceStore.getState().leaveVoice(); useVoiceStore.getState().leaveVoice();
} }
if (disconnectFn) disconnectFn();
}; };
const statusColor = connectionError const statusColor = connectionError
+20 -5
View File
@@ -302,13 +302,19 @@ 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 } = useVoiceStore.getState(); const { activeDmCall, setActiveDmCall, setIncomingCall, incomingCall, connectFn, disconnectFn } = 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) {
const isParticipant = call.participants.includes(myId); const isParticipant = call.participants.includes(myId);
if (call.state === 'active' && isParticipant) { if (call.state === 'active' && isParticipant) {
setActiveDmCall({ dmChannelId: call.dmChannelId }); setActiveDmCall({ dmChannelId: call.dmChannelId });
// Re-establish LiveKit connection for DM call if needed (WS reconnect)
if (connectFn) {
connectFn(call.dmChannelId, true).catch((err) => {
console.error('[WS] DM call reconnect failed:', err);
});
}
break; break;
} else if (call.state === 'ringing' && call.callerId !== myId) { } else if (call.state === 'ringing' && call.callerId !== myId) {
const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId); const dmCh = event.dmChannels?.find((d: any) => d.id === call.dmChannelId);
@@ -323,6 +329,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
} else { } else {
if (activeDmCall) { if (activeDmCall) {
setActiveDmCall(null); setActiveDmCall(null);
if (disconnectFn) disconnectFn();
} }
if (incomingCall) { if (incomingCall) {
setIncomingCall(null); setIncomingCall(null);
@@ -466,7 +473,7 @@ function handleEvent(origin: string, event: ServerEvent): void {
const vs = useVoiceStore.getState(); const vs = useVoiceStore.getState();
// Clear current channel first so joinVoiceChannel doesn't bail // Clear current channel first so joinVoiceChannel doesn't bail
vs.setCurrentVoiceChannel(null); vs.setCurrentVoiceChannel(null);
joinVoiceChannel(event.newChannelId); joinVoiceChannel(event.newChannelId, vs.connectFn ?? undefined);
}); });
} }
break; break;
@@ -693,26 +700,34 @@ function handleEvent(origin: string, event: ServerEvent): void {
} }
case 'dm_call_accepted': { case 'dm_call_accepted': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); const { setIncomingCall, setOutgoingCall, setActiveDmCall, connectFn } = useVoiceStore.getState();
setIncomingCall(null); setIncomingCall(null);
setOutgoingCall(null); setOutgoingCall(null);
setActiveDmCall({ dmChannelId: event.dmChannelId }); setActiveDmCall({ dmChannelId: event.dmChannelId });
// Initiate LiveKit connection for the DM call
if (connectFn) {
connectFn(event.dmChannelId, true).catch((err) => {
console.error('[WS] DM call connect failed:', err);
});
}
break; break;
} }
case 'dm_call_rejected': { case 'dm_call_rejected': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn } = useVoiceStore.getState();
setIncomingCall(null); setIncomingCall(null);
setOutgoingCall(null); setOutgoingCall(null);
setActiveDmCall(null); setActiveDmCall(null);
if (disconnectFn) disconnectFn();
break; break;
} }
case 'dm_call_ended': { case 'dm_call_ended': {
const { setIncomingCall, setOutgoingCall, setActiveDmCall } = useVoiceStore.getState(); const { setIncomingCall, setOutgoingCall, setActiveDmCall, disconnectFn } = useVoiceStore.getState();
setIncomingCall(null); setIncomingCall(null);
setOutgoingCall(null); setOutgoingCall(null);
setActiveDmCall(null); setActiveDmCall(null);
if (disconnectFn) disconnectFn();
break; break;
} }
+11
View File
@@ -116,6 +116,11 @@ interface VoiceState {
clearVoiceUsersForOrigin: (origin: string) => void; clearVoiceUsersForOrigin: (origin: string) => void;
leaveVoice: () => void; leaveVoice: () => void;
handleForceDisconnect: () => void; handleForceDisconnect: () => void;
// Gesture-aware connect/disconnect refs — registered by AppLayout from useLiveKit()
connectFn: ((channelId: string, isDm?: boolean) => Promise<void>) | null;
disconnectFn: (() => Promise<void>) | null;
setConnectFn: (fn: ((channelId: string, isDm?: boolean) => Promise<void>) | null) => void;
setDisconnectFn: (fn: (() => Promise<void>) | null) => void;
reset: () => void; reset: () => void;
} }
@@ -471,6 +476,12 @@ export const useVoiceStore = create<VoiceState>()(
}); });
}, },
// Gesture-aware connect/disconnect refs — set by AppLayout, read by click handlers
connectFn: null,
disconnectFn: null,
setConnectFn: (fn) => set({ connectFn: fn }),
setDisconnectFn: (fn) => set({ disconnectFn: fn }),
// Force disconnect: clear local connection state but do NOT touch voiceUsers. // Force disconnect: clear local connection state but do NOT touch voiceUsers.
// Used for involuntary disconnects (identity collision, server shutdown, etc.) // Used for involuntary disconnects (identity collision, server shutdown, etc.)
// where the server is the authority on who's actually in voice. // where the server is the authority on who's actually in voice.
+19 -2
View File
@@ -65,8 +65,17 @@ export function broadcastDeafenViaLiveKit(): void {
* When switching from a channel on Instance A to one on Instance B, * When switching from a channel on Instance A to one on Instance B,
* this sends an explicit voice_leave to Instance A first so it * this sends an explicit voice_leave to Instance A first so it
* broadcasts a leave event and the client cleans up stale voice state. * broadcasts a leave event and the client cleans up stale voice state.
*
* @param channelId The channel to join.
* @param connectFn The LiveKit connect function, obtained from
* `useVoiceStore.getState().connectFn`. When provided the
* LiveKit connection is initiated directly within the
* caller's gesture context (required on iOS).
*/ */
export function joinVoiceChannel(channelId: string): void { export function joinVoiceChannel(
channelId: string,
connectFn?: (channelId: string, isDm?: boolean) => Promise<void>,
): void {
const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState(); const { currentVoiceChannelId, setCurrentVoiceChannel, addVoiceUser, removeVoiceUser } = useVoiceStore.getState();
if (currentVoiceChannelId === channelId) return; if (currentVoiceChannelId === channelId) return;
@@ -83,8 +92,16 @@ export function joinVoiceChannel(channelId: string): void {
} }
setCurrentVoiceChannel(channelId); setCurrentVoiceChannel(channelId);
// voice_join is now sent by useLiveKit after successful LiveKit connection
// Optimistic: immediately show self in new channel (using origin-aware ID) // Optimistic: immediately show self in new channel (using origin-aware ID)
const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId)); const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId));
if (myNewId) addVoiceUser(channelId, myNewId); if (myNewId) addVoiceUser(channelId, myNewId);
// Direct connection within gesture context
if (connectFn) {
connectFn(channelId).catch((err) => {
console.error('[voice] Connection failed:', err);
setCurrentVoiceChannel(null);
if (myNewId) removeVoiceUser(channelId, myNewId);
});
}
} }
+4 -1
View File
@@ -85,7 +85,7 @@ export async function handleScreenShareAction(): Promise<void> {
*/ */
export function handleDisconnectAction(): void { export function handleDisconnectAction(): void {
const voice = useVoiceStore.getState(); const voice = useVoiceStore.getState();
const { activeDmCall, currentVoiceChannelId } = voice; const { activeDmCall, currentVoiceChannelId, disconnectFn } = voice;
if (activeDmCall) { if (activeDmCall) {
wsSend( wsSend(
@@ -99,6 +99,9 @@ export function handleDisconnectAction(): void {
voice.leaveVoice(); voice.leaveVoice();
} }
// Tear down the LiveKit connection
if (disconnectFn) disconnectFn();
// Exit fullscreen if active // Exit fullscreen if active
const voiceFullscreen = useUIStore.getState().voiceFullscreen; const voiceFullscreen = useUIStore.getState().voiceFullscreen;
if (voiceFullscreen) { if (voiceFullscreen) {