feat(voice): jump to the call from the voice panel

The channel name under 'Voice Connected' was a plain div. Making it navigate
needed more than an onClick: voiceStore never recorded which space the call
was in, and spaceStore.channels only holds the space currently being viewed —
so after navigating away the call's channel was unresolvable, which is also
why the label degraded to a generic 'Voice Channel'.

Capture space and channel name at join time (the only moment they are
reliable) and use them for both the label and the jump. Covers space calls
and DM calls.
This commit is contained in:
2026-08-31 11:40:45 -03:00
parent 08db5374cb
commit c70b0095a9
3 changed files with 59 additions and 7 deletions
@@ -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() {
<div className={`text-[13px] font-semibold leading-[18px] ${statusColor}`}>
{connectionError ? 'Connection Failed' : isLiveKitConnected ? 'Voice Connected' : 'Connecting...'}
</div>
{canGoToCall ? (
<button
onClick={handleGoToCall}
title="Go to call"
className="text-[12px] text-txt-tertiary truncate leading-[16px] w-full text-left hover:text-txt-primary hover:underline transition-colors cursor-pointer"
>
{channelName}
</button>
) : (
<div className="text-[12px] text-txt-tertiary truncate leading-[16px]">
{connectionError ? connectionError : channelName}
</div>
)}
</div>
<div className="flex items-center gap-0.5 flex-shrink-0">
+17 -2
View File
@@ -17,6 +17,15 @@ export interface ScreenShareConfig {
interface VoiceState {
voiceUsers: Map<string, string[]>; // 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<string>) => void;
setConnectionError: (error: string | null) => void;
@@ -158,6 +167,8 @@ export const useVoiceStore = create<VoiceState>()(
(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<VoiceState>()(
});
},
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<VoiceState>()(
voiceUsers: new Map(),
voiceUserStates: new Map(),
currentVoiceChannelId: null,
currentVoiceSpaceId: null,
currentVoiceChannelName: null,
participants: [],
speakingParticipantIds: new Set(),
speakingUserIds: new Set(),
+9 -1
View File
@@ -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);