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 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
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user