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:
@@ -108,18 +108,24 @@ export function AppLayout() {
|
||||
|
||||
const channels = useSpaceStore((s) => s.channels);
|
||||
|
||||
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
|
||||
const activeDmCall = useVoiceStore((s) => s.activeDmCall);
|
||||
const {
|
||||
connect: connectVoice,
|
||||
disconnect: disconnectVoice,
|
||||
isConnected: isVoiceConnected,
|
||||
isConnecting: isVoiceConnecting,
|
||||
connectedChannelId,
|
||||
} = 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
|
||||
const { isConnected: isWsConnected } = useWebSocket();
|
||||
useWebSocket();
|
||||
|
||||
// Federation toast notifications for remote instance connection state changes
|
||||
useFederationToasts();
|
||||
@@ -136,67 +142,6 @@ export function AppLayout() {
|
||||
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
|
||||
useEffect(() => {
|
||||
const checkMobile = () => setIsMobile(window.innerWidth < 768);
|
||||
|
||||
@@ -403,7 +403,8 @@ export function ChannelSidebar() {
|
||||
navigate(`/channels/${currentSpaceId}/${channelId}`);
|
||||
return;
|
||||
}
|
||||
joinVoiceChannel(channelId);
|
||||
const connectFn = useVoiceStore.getState().connectFn;
|
||||
joinVoiceChannel(channelId, connectFn ?? undefined);
|
||||
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>
|
||||
</div>
|
||||
<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)]"
|
||||
>
|
||||
Join Voice
|
||||
|
||||
@@ -194,8 +194,12 @@ export function MobileSpacesScreen() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleVoiceJoin = useCallback((chId: string, _preMuted: boolean) => {
|
||||
joinVoiceChannel(chId);
|
||||
const handleVoiceJoin = useCallback((chId: string, preMuted: boolean) => {
|
||||
if (preMuted) {
|
||||
useVoiceStore.getState().setMuted(true);
|
||||
}
|
||||
const connectFn = useVoiceStore.getState().connectFn;
|
||||
joinVoiceChannel(chId, connectFn ?? undefined);
|
||||
setVoiceJoinChannelId(null);
|
||||
pushMobileScreen('voice');
|
||||
}, [pushMobileScreen]);
|
||||
|
||||
@@ -97,6 +97,8 @@ export function MobileVoiceFullScreen() {
|
||||
|
||||
const handleDisconnect = () => {
|
||||
leaveVoice();
|
||||
const disconnectFn = useVoiceStore.getState().disconnectFn;
|
||||
if (disconnectFn) disconnectFn();
|
||||
popMobileScreen();
|
||||
};
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ export function MobileVoiceMiniBar() {
|
||||
</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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||
|
||||
Reference in New Issue
Block a user