diff --git a/packages/web/src/components/voice/VoiceControls.tsx b/packages/web/src/components/voice/VoiceControls.tsx index c0e45c60..c31494fd 100644 --- a/packages/web/src/components/voice/VoiceControls.tsx +++ b/packages/web/src/components/voice/VoiceControls.tsx @@ -1,5 +1,7 @@ import React, { useState, useRef } from 'react'; +import { useNavigate } from 'react-router-dom'; import { useVoiceStore } from '../../stores/voiceStore'; +import { useChatStore } from '../../stores/chatStore'; import { useSpaceStore, getChannelOrigin } from '../../stores/spaceStore'; import { getActiveRoom } from '../../hooks/useLiveKit'; import { wsSend } from '../../hooks/useWebSocket'; @@ -16,6 +18,10 @@ import { handleCameraAction } from '../../utils/voiceActions'; */ export function VoiceControls() { const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId); + const currentVoiceSpaceId = useVoiceStore((s) => s.currentVoiceSpaceId); + const currentVoiceChannelName = useVoiceStore((s) => s.currentVoiceChannelName); + const setCurrentChannel = useChatStore((s) => s.setCurrentChannel); + const navigate = useNavigate(); const isCameraOn = useVoiceStore((s) => s.isCameraOn); const isScreenSharing = useVoiceStore((s) => s.isScreenSharing); const rnnoiseEnabled = useVoiceStore((s) => s.rnnoiseEnabled); @@ -42,7 +48,20 @@ export function VoiceControls() { if (!currentVoiceChannelId && !activeDmCall) return null; const channel = channels.find(c => c.id === currentVoiceChannelId); - const channelName = channel?.name ?? (activeDmCall ? 'DM Call' : 'Voice Channel'); + const channelName = + channel?.name ?? currentVoiceChannelName ?? (activeDmCall ? 'DM Call' : 'Voice Channel'); + + // Jump back to where the call is happening. DM calls live under @me; space + // calls under the space captured at join time. + const callSpaceId = activeDmCall ? '@me' : currentVoiceSpaceId; + const callChannelId = activeDmCall ? activeDmCall.dmChannelId : currentVoiceChannelId; + const canGoToCall = Boolean(callChannelId && callSpaceId && !connectionError); + + const handleGoToCall = () => { + if (!canGoToCall || !callChannelId) return; + setCurrentChannel(callChannelId); + navigate(`/channels/${callSpaceId}/${callChannelId}`); + }; const handleScreenShare = async () => { const room = getActiveRoom(); @@ -120,9 +139,19 @@ export function VoiceControls() {
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
-
- {connectionError ? connectionError : channelName} -
+ {canGoToCall ? ( + + ) : ( +
+ {connectionError ? connectionError : channelName} +
+ )}
diff --git a/packages/web/src/stores/voiceStore.ts b/packages/web/src/stores/voiceStore.ts index 82b486db..6e54b7d1 100644 --- a/packages/web/src/stores/voiceStore.ts +++ b/packages/web/src/stores/voiceStore.ts @@ -17,6 +17,15 @@ export interface ScreenShareConfig { interface VoiceState { voiceUsers: Map; // channelId → userIds currentVoiceChannelId: string | null; + /** + * Space and name of the channel the call is in, captured at join time. + * `channels` in spaceStore only holds the space the user is *viewing*, so + * once they navigate elsewhere the call's channel is no longer resolvable + * from it — these keep the voice panel able to name and link to the call. + * Transient: intentionally absent from `partialize`. + */ + currentVoiceSpaceId: string | null; + currentVoiceChannelName: string | null; isMuted: boolean; isDeafened: boolean; isCameraOn: boolean; @@ -85,7 +94,7 @@ interface VoiceState { setVoiceUsers: (channelId: string, userIds: string[]) => void; addVoiceUser: (channelId: string, userId: string) => void; removeVoiceUser: (channelId: string, userId: string) => void; - setCurrentVoiceChannel: (channelId: string | null) => void; + setCurrentVoiceChannel: (channelId: string | null, spaceId?: string | null, channelName?: string | null) => void; setParticipants: (participants: ParticipantInfo[]) => void; setSpeakingParticipants: (ids: Set) => void; setConnectionError: (error: string | null) => void; @@ -158,6 +167,8 @@ export const useVoiceStore = create()( (set, get) => ({ voiceUsers: new Map(), currentVoiceChannelId: null, + currentVoiceSpaceId: null, + currentVoiceChannelName: null, isMuted: false, pttActive: false, isDeafened: false, @@ -349,8 +360,10 @@ export const useVoiceStore = create()( }); }, - setCurrentVoiceChannel: (channelId) => set({ + setCurrentVoiceChannel: (channelId, spaceId = null, channelName = null) => set({ currentVoiceChannelId: channelId, + currentVoiceSpaceId: channelId ? spaceId : null, + currentVoiceChannelName: channelId ? channelName : null, activeDmCall: null // Clear active DM call when joining a server channel }), @@ -516,6 +529,8 @@ export const useVoiceStore = create()( voiceUsers: new Map(), voiceUserStates: new Map(), currentVoiceChannelId: null, + currentVoiceSpaceId: null, + currentVoiceChannelName: null, participants: [], speakingParticipantIds: new Set(), speakingUserIds: new Set(), diff --git a/packages/web/src/utils/voice.ts b/packages/web/src/utils/voice.ts index 65fd9892..b6bb5873 100644 --- a/packages/web/src/utils/voice.ts +++ b/packages/web/src/utils/voice.ts @@ -152,7 +152,15 @@ export function joinVoiceChannel( if (myOldId) removeVoiceUser(currentVoiceChannelId, myOldId); } - setCurrentVoiceChannel(channelId); + // Capture the space and name now: a voice channel can only be joined from + // within its own space, but the user may navigate away afterwards — at which + // point spaceStore.channels no longer resolves this channel. + const spaceStore = useSpaceStore.getState(); + setCurrentVoiceChannel( + channelId, + spaceStore.currentSpaceId ?? null, + spaceStore.channels.find((c) => c.id === channelId)?.name ?? null, + ); // Optimistic: immediately show self in new channel (using origin-aware ID) const myNewId = getMyUserIdForOrigin(getChannelOrigin(channelId)); if (myNewId) addVoiceUser(channelId, myNewId);