fix: resolve LiveKit voice/video stability issues

- Guard Disconnected/ConnectionStateChanged event handlers against stale rooms:
  old room events no longer nuke the new room's state (root cause of buttons
  failing, mute getting stuck, DUPLICATE_IDENTITY cascades)
- Reset media state (isMuted/isCameraOn/isScreenSharing) on connect to prevent
  desync after reconnects
- Add voiceStates to WS ready payload so users see who's in voice on page load
- Wire VoiceControls buttons to check getActiveRoom() before SDK calls
- Guard ChannelSidebar against re-joining the same voice channel
- Switch LIVEKIT_URL to wss://nova.ddns.net/livekit for HTTPS secure context
  (required for getUserMedia in Safari)
This commit is contained in:
Jannis Braun
2026-02-18 07:52:25 +01:00
parent 5ef502f2e3
commit 4f65ea9c86
27 changed files with 573 additions and 143 deletions
+5 -4
View File
@@ -8,13 +8,14 @@ export const useChatStore = create((set, get) => ({
typingUsers: new Map(),
hasMore: new Map(),
isLoading: false,
loadError: null,
replyTo: null,
setCurrentChannel: (channelId) => set({ currentChannelId: channelId }),
setReplyTo: (message) => set({ replyTo: message }),
loadMessages: async (channelId) => {
if (get().messages.has(channelId))
return;
set({ isLoading: true });
set({ isLoading: true, loadError: null });
try {
const isDm = useUIStore.getState().showDms;
const messages = isDm
@@ -25,11 +26,11 @@ export const useChatStore = create((set, get) => ({
newMessages.set(channelId, messages);
const newHasMore = new Map(state.hasMore);
newHasMore.set(channelId, messages.length >= 50);
return { messages: newMessages, hasMore: newHasMore, isLoading: false };
return { messages: newMessages, hasMore: newHasMore, isLoading: false, loadError: null };
});
}
catch {
set({ isLoading: false });
catch (err) {
set({ isLoading: false, loadError: err.message || 'Failed to load messages' });
}
},
loadMoreMessages: async (channelId) => {
+9
View File
@@ -75,6 +75,15 @@ export const useServerStore = create((set, get) => ({
return { servers: [...state.servers, server] };
});
},
joinByCode: async (inviteCode) => {
const server = await api.servers.joinByCode(inviteCode);
set((state) => {
if (state.servers.find(s => s.id === server.id))
return state;
return { servers: [...state.servers, server] };
});
return server;
},
generateInvite: async (serverId) => {
const result = await api.servers.invite(serverId);
return result.inviteCode;
+29
View File
@@ -50,6 +50,20 @@ export const useSocialStore = create((set, get) => ({
throw err;
}
},
cancelFriendRequest: async (id) => {
set({ isLoading: true, error: null });
try {
await api.social.cancelRequest(id);
set((state) => ({
requests: state.requests.filter(r => r.id !== id),
isLoading: false,
}));
}
catch (err) {
set({ error: err.message, isLoading: false });
throw err;
}
},
removeFriend: async (id) => {
set({ isLoading: true, error: null });
try {
@@ -73,4 +87,19 @@ export const useSocialStore = create((set, get) => ({
return [];
}
},
// Called from WS handler when another user sends you a friend request
addIncomingRequest: (request) => {
set((state) => {
if (state.requests.find(r => r.id === request.id))
return state;
return { requests: [...state.requests, request] };
});
},
// Called from WS handler when someone accepts your friend request
addFriendFromAccepted: (friend, requestId) => {
set((state) => ({
friends: state.friends.find(f => f.id === friend.id) ? state.friends : [...state.friends, friend],
requests: state.requests.filter(r => r.id !== requestId),
}));
},
}));
+7
View File
@@ -7,6 +7,8 @@ export const useVoiceStore = create((set, get) => ({
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
setVoiceUsers: (channelId, userIds) => {
set((state) => {
const newMap = new Map(state.voiceUsers);
@@ -34,6 +36,8 @@ export const useVoiceStore = create((set, get) => ({
},
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
setParticipants: (participants) => set({ participants }),
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
toggleCamera: () => set((state) => ({ isCameraOn: !state.isCameraOn })),
@@ -46,5 +50,8 @@ export const useVoiceStore = create((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
}),
}));
+11
View File
@@ -9,11 +9,15 @@ interface VoiceState {
isCameraOn: boolean;
isScreenSharing: boolean;
participants: ParticipantInfo[];
connectionError: string | null;
isLiveKitConnected: boolean;
setVoiceUsers: (channelId: string, userIds: string[]) => void;
addVoiceUser: (channelId: string, userId: string) => void;
removeVoiceUser: (channelId: string, userId: string) => void;
setCurrentVoiceChannel: (channelId: string | null) => void;
setParticipants: (participants: ParticipantInfo[]) => void;
setConnectionError: (error: string | null) => void;
setIsLiveKitConnected: (connected: boolean) => void;
toggleMic: () => void;
toggleCamera: () => void;
toggleScreenShare: () => void;
@@ -30,6 +34,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
setVoiceUsers: (channelId, userIds) => {
set((state) => {
@@ -62,6 +68,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
setCurrentVoiceChannel: (channelId) => set({ currentVoiceChannelId: channelId }),
setParticipants: (participants) => set({ participants }),
setConnectionError: (error) => set({ connectionError: error }),
setIsLiveKitConnected: (connected) => set({ isLiveKitConnected: connected }),
toggleMic: () => set((state) => ({ isMuted: !state.isMuted })),
toggleDeafen: () => set((state) => ({ isDeafened: !state.isDeafened })),
@@ -77,5 +85,8 @@ export const useVoiceStore = create<VoiceState>((set, get) => ({
isDeafened: false,
isCameraOn: false,
isScreenSharing: false,
participants: [],
connectionError: null,
isLiveKitConnected: false,
}),
}));