This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { Room, RoomEvent, VideoPresets, ConnectionQuality, DisconnectReason } from 'livekit-client';
|
||||
import { LiveKitRoom, RoomAudioRenderer } from '@livekit/components-react';
|
||||
import { useQuery, useConvex } from 'convex/react';
|
||||
@@ -99,13 +99,42 @@ export const VoiceProvider = ({ children }) => {
|
||||
|
||||
const convex = useConvex();
|
||||
|
||||
// Single source of truth for the signed-in user id. All the
|
||||
// voice/presence effects below used to read localStorage
|
||||
// directly — that worked but wasn't reactive, so a
|
||||
// logout-then-login in the same tab could leave effects
|
||||
// operating on a stale id until something else triggered a
|
||||
// rerender. The `storage` and `brycord:auth-change` listeners
|
||||
// keep this state in sync across tabs (the former) and
|
||||
// within-tab login/logout (the latter, emitted by
|
||||
// hooks/useLogout + the login page).
|
||||
const [myUserId, setMyUserId] = useState(
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem('userId') : null,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
const sync = () => {
|
||||
setMyUserId(
|
||||
typeof localStorage !== 'undefined'
|
||||
? localStorage.getItem('userId')
|
||||
: null,
|
||||
);
|
||||
};
|
||||
window.addEventListener('storage', sync);
|
||||
window.addEventListener('brycord:auth-change', sync);
|
||||
return () => {
|
||||
window.removeEventListener('storage', sync);
|
||||
window.removeEventListener('brycord:auth-change', sync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Stream watching state (lifted from VoiceStage so PiP can persist across navigation)
|
||||
const [watchingStreamOf, setWatchingStreamOfRaw] = useState(null);
|
||||
|
||||
const setWatchingStreamOf = useCallback((identity) => {
|
||||
setWatchingStreamOfRaw(identity);
|
||||
// Sync to backend
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userId = myUserId;
|
||||
if (userId) {
|
||||
convex.mutation(api.voiceState.setWatchingStream, {
|
||||
userId,
|
||||
@@ -120,7 +149,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
|
||||
const clearWatchingStream = useCallback(() => {
|
||||
setWatchingStreamOfRaw(null);
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userId = myUserId;
|
||||
if (userId) {
|
||||
convex.mutation(api.voiceState.setWatchingStream, { userId }).catch(
|
||||
e => console.error('Failed to clear watching stream:', e)
|
||||
@@ -205,7 +234,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
const isPersonallyMuted = (userId) => personallyMutedUsers.has(userId);
|
||||
|
||||
const serverMute = async (targetUserId, isServerMuted) => {
|
||||
const actorUserId = localStorage.getItem('userId');
|
||||
const actorUserId = myUserId;
|
||||
if (!actorUserId) return;
|
||||
try {
|
||||
await convex.mutation(api.voiceState.serverMute, { actorUserId, targetUserId, isServerMuted });
|
||||
@@ -215,7 +244,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const disconnectUser = async (targetUserId) => {
|
||||
const actorUserId = localStorage.getItem('userId');
|
||||
const actorUserId = myUserId;
|
||||
if (!actorUserId) return;
|
||||
try {
|
||||
await convex.mutation(api.voiceState.disconnectUser, { actorUserId, targetUserId });
|
||||
@@ -236,7 +265,6 @@ export const VoiceProvider = ({ children }) => {
|
||||
const serverSettings = useQuery(api.serverSettings.get);
|
||||
|
||||
// Subscribe to own join sound URL for self-join playback
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
const myJoinSoundUrl = useQuery(
|
||||
api.auth.getMyJoinSoundUrl,
|
||||
myUserId ? { userId: myUserId } : "skip"
|
||||
@@ -248,7 +276,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
const isInAfkChannel = !!(activeChannelId && serverSettings?.afkChannelId === activeChannelId);
|
||||
|
||||
async function updateVoiceState(fields) {
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userId = myUserId;
|
||||
if (!userId || !activeChannelId) return;
|
||||
try {
|
||||
await convex.mutation(api.voiceState.updateState, { userId, ...fields });
|
||||
@@ -280,12 +308,36 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const { token: lkToken } = await convex.action(api.voice.getToken, {
|
||||
// Prove we control `userId` by signing the (userId, channelId,
|
||||
// timestamp) tuple with the Ed25519 key decrypted at login.
|
||||
// The server verifies with our public signing key before minting
|
||||
// a LiveKit JWT, so voice rooms can't be joined by forging args.
|
||||
const signingKey = sessionStorage.getItem('signingKey');
|
||||
if (!signingKey) {
|
||||
console.error('Missing signing key — cannot authorize voice join');
|
||||
setConnectionState('error');
|
||||
setActiveChannelId(null);
|
||||
return;
|
||||
}
|
||||
const timestamp = Date.now();
|
||||
const message = `voice-token:${userId}:${channelId}:${timestamp}`;
|
||||
const signature = await platform.crypto.signMessage(signingKey, message);
|
||||
|
||||
const tokenResult = await convex.action(api.voice.getToken, {
|
||||
channelId,
|
||||
userId,
|
||||
username: localStorage.getItem('username') || 'Unknown'
|
||||
timestamp,
|
||||
signature,
|
||||
});
|
||||
|
||||
if ('error' in tokenResult) {
|
||||
console.error('Voice token rejected:', tokenResult.error);
|
||||
setConnectionState('error');
|
||||
setActiveChannelId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const lkToken = tokenResult.token;
|
||||
if (!lkToken) throw new Error('Failed to get token');
|
||||
|
||||
setToken(lkToken);
|
||||
@@ -515,7 +567,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
// Heartbeat: send periodic heartbeat to prevent ghost voice states
|
||||
useEffect(() => {
|
||||
if (!activeChannelId) return;
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userId = myUserId;
|
||||
if (!userId) return;
|
||||
|
||||
const sendHeartbeat = () => {
|
||||
@@ -530,10 +582,17 @@ export const VoiceProvider = ({ children }) => {
|
||||
return () => clearInterval(interval);
|
||||
}, [activeChannelId, convex]);
|
||||
|
||||
// Handle notification action buttons (Android foreground service)
|
||||
// Handle notification action buttons (Android foreground service).
|
||||
// Capacitor's plugin API has historically shifted between returning
|
||||
// a listener handle synchronously and returning a Promise, so we
|
||||
// normalize both shapes into the same cleanup code instead of
|
||||
// leaving a silent no-op when neither matches.
|
||||
useEffect(() => {
|
||||
if (!voiceService) return;
|
||||
const listener = voiceService.addNotificationActionListener((event) => {
|
||||
let cancelled = false;
|
||||
let resolvedHandle = null;
|
||||
|
||||
const handle = voiceService.addNotificationActionListener((event) => {
|
||||
switch (event.action) {
|
||||
case 'disconnect':
|
||||
disconnectVoice();
|
||||
@@ -546,17 +605,43 @@ export const VoiceProvider = ({ children }) => {
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
if (handle && typeof handle.then === 'function') {
|
||||
handle.then((l) => {
|
||||
if (cancelled) {
|
||||
l?.remove?.();
|
||||
} else {
|
||||
resolvedHandle = l;
|
||||
}
|
||||
}).catch(() => { /* nothing to clean up */ });
|
||||
} else {
|
||||
resolvedHandle = handle;
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (listener && listener.remove) listener.remove();
|
||||
else if (listener && typeof listener.then === 'function') {
|
||||
listener.then(l => l?.remove?.());
|
||||
cancelled = true;
|
||||
try {
|
||||
resolvedHandle?.remove?.();
|
||||
} catch (e) {
|
||||
console.warn('Failed to remove notification listener:', e);
|
||||
}
|
||||
};
|
||||
}, [voiceService, activeChannelId]);
|
||||
|
||||
// Detect when another user moves us to a different voice channel
|
||||
// Detect when another user moves us to a different voice channel.
|
||||
//
|
||||
// `connectToVoice` is a plain function (not useCallback), so listing
|
||||
// it as a dep would re-run this effect on every render — an infinite
|
||||
// reconnect loop. We stash the latest reference in a ref so the
|
||||
// effect can call the freshest copy without triggering itself. The
|
||||
// audit flagged the old setup for potentially using stale
|
||||
// credentials if the move fired mid-render; the ref closes that gap.
|
||||
const connectToVoiceRef = useRef(connectToVoice);
|
||||
useEffect(() => {
|
||||
connectToVoiceRef.current = connectToVoice;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
if (!myUserId || !activeChannelId || isMovingRef.current) return;
|
||||
|
||||
// Find which channel the server says we're in
|
||||
@@ -571,11 +656,12 @@ export const VoiceProvider = ({ children }) => {
|
||||
// If server says we're in a different channel, reconnect
|
||||
if (serverChannelId && serverChannelId !== activeChannelId) {
|
||||
isMovingRef.current = true;
|
||||
const currentRoom = room;
|
||||
(async () => {
|
||||
try {
|
||||
const channel = await convex.query(api.channels.get, { id: serverChannelId });
|
||||
if (room) await room.disconnect();
|
||||
await connectToVoice(serverChannelId, channel?.name || 'Voice', myUserId);
|
||||
if (currentRoom) await currentRoom.disconnect();
|
||||
await connectToVoiceRef.current(serverChannelId, channel?.name || 'Voice', myUserId);
|
||||
} catch (e) {
|
||||
console.error('Failed to reconnect after move:', e);
|
||||
} finally {
|
||||
@@ -583,18 +669,17 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
})();
|
||||
}
|
||||
}, [voiceStates, activeChannelId]);
|
||||
}, [voiceStates, activeChannelId, room, convex, myUserId]);
|
||||
|
||||
// Enforce server mute: force-disable mic when server muted, restore when lifted
|
||||
useEffect(() => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
if (!myUserId || !room) return;
|
||||
if (isServerMuted(myUserId)) {
|
||||
room.localParticipant.setMicrophoneEnabled(false);
|
||||
} else if (!isMuted && !isDeafened) {
|
||||
room.localParticipant.setMicrophoneEnabled(true);
|
||||
}
|
||||
}, [voiceStates, room]);
|
||||
}, [voiceStates, room, myUserId]);
|
||||
|
||||
// Re-apply personal mutes/volumes when room or participants change
|
||||
useEffect(() => {
|
||||
@@ -637,12 +722,25 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
if (idleSeconds >= afkTimeout) {
|
||||
const userId = localStorage.getItem('userId');
|
||||
const userId = myUserId;
|
||||
if (!userId) return;
|
||||
|
||||
// On Capacitor, also set user status to idle
|
||||
if (isCapacitor) {
|
||||
await convex.mutation(api.auth.updateStatus, { userId, status: 'idle' });
|
||||
const signingKey = sessionStorage.getItem('signingKey');
|
||||
if (signingKey) {
|
||||
const authTimestamp = Date.now();
|
||||
const authSignature = await platform.crypto.signMessage(
|
||||
signingKey,
|
||||
`updateStatus:${userId}:idle:${authTimestamp}`,
|
||||
);
|
||||
await convex.action(api.authActions.updateStatus, {
|
||||
userId,
|
||||
status: 'idle',
|
||||
authTimestamp,
|
||||
authSignature,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await convex.mutation(api.voiceState.afkMove, {
|
||||
@@ -670,7 +768,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const selfId = localStorage.getItem('userId');
|
||||
const selfId = myUserId;
|
||||
const channelUsers = voiceStates[activeChannelId] || [];
|
||||
const currentUserIds = new Set(channelUsers.map(u => u.userId));
|
||||
|
||||
@@ -702,7 +800,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
prevChannelUsersRef.current = currentUserIds;
|
||||
}, [voiceStates, activeChannelId]);
|
||||
}, [voiceStates, activeChannelId, myUserId]);
|
||||
|
||||
// Manage screen share subscriptions — only subscribe when actively watching
|
||||
useEffect(() => {
|
||||
@@ -794,7 +892,6 @@ export const VoiceProvider = ({ children }) => {
|
||||
return;
|
||||
}
|
||||
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
// Collect all users currently watching the same stream
|
||||
const currentViewers = new Set();
|
||||
for (const users of Object.values(voiceStates)) {
|
||||
@@ -831,7 +928,7 @@ export const VoiceProvider = ({ children }) => {
|
||||
}
|
||||
|
||||
prevViewersRef.current = currentViewers;
|
||||
}, [voiceStates, watchingStreamOf]);
|
||||
}, [voiceStates, watchingStreamOf, myUserId]);
|
||||
|
||||
// Detect screen-share publications starting / stopping across the
|
||||
// active voice channel (including the local user) and play a
|
||||
@@ -884,29 +981,54 @@ export const VoiceProvider = ({ children }) => {
|
||||
};
|
||||
|
||||
const toggleMute = async () => {
|
||||
const myUserId = localStorage.getItem('userId');
|
||||
// Block unmute if server muted or in AFK channel
|
||||
if (isMuted && myUserId && isServerMuted(myUserId)) return;
|
||||
if (isMuted && isInAfkChannel) return;
|
||||
const nextState = !isMuted;
|
||||
// Flip LiveKit first. If this rejects we bail before committing any
|
||||
// UI state — otherwise the user sees "muted" while their mic is
|
||||
// still publishing to everyone in the room (a privacy leak, not
|
||||
// just a UX nit).
|
||||
if (room) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle microphone:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsMuted(nextState);
|
||||
playSound(nextState ? 'mute' : 'unmute');
|
||||
voiceService?.updateNotification({ isMuted: nextState });
|
||||
if (room) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
try {
|
||||
await updateVoiceState({ isMuted: nextState });
|
||||
} catch (e) {
|
||||
// LiveKit is already in the right state, so audio is safe;
|
||||
// the server's voice-states row is just stale. Other clients
|
||||
// will pick up the correct value from our next successful
|
||||
// mutation or heartbeat. Log and move on.
|
||||
console.error('Failed to sync mute state to server:', e);
|
||||
}
|
||||
await updateVoiceState({ isMuted: nextState });
|
||||
};
|
||||
|
||||
const toggleDeafen = async () => {
|
||||
const nextState = !isDeafened;
|
||||
if (room && !isMuted) {
|
||||
try {
|
||||
await room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle microphone for deafen:', e);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setIsDeafened(nextState);
|
||||
playSound(nextState ? 'deafen' : 'undeafen');
|
||||
voiceService?.updateNotification({ isDeafened: nextState });
|
||||
if (room && !isMuted) {
|
||||
room.localParticipant.setMicrophoneEnabled(!nextState);
|
||||
try {
|
||||
await updateVoiceState({ isDeafened: nextState });
|
||||
} catch (e) {
|
||||
console.error('Failed to sync deafen state to server:', e);
|
||||
}
|
||||
await updateVoiceState({ isDeafened: nextState });
|
||||
};
|
||||
|
||||
// Actually flip the LiveKit screen-share publication on/off. The
|
||||
@@ -917,15 +1039,32 @@ export const VoiceProvider = ({ children }) => {
|
||||
// resulting track, and tears it down on false.
|
||||
const setScreenSharing = async (active) => {
|
||||
if (!room) return;
|
||||
// Snapshot the screen-share publications *before* disabling so we
|
||||
// can explicitly stop the underlying MediaStreamTracks afterwards.
|
||||
// LiveKit's `setScreenShareEnabled(false)` unpublishes but doesn't
|
||||
// always fully release the getDisplayMedia tracks before returning,
|
||||
// which caused intermittent "NotAllowedError: Permission denied"
|
||||
// when the user re-shared immediately.
|
||||
const toStop = [];
|
||||
if (!active) {
|
||||
const pubs = room.localParticipant?.trackPublications;
|
||||
if (pubs?.forEach) {
|
||||
pubs.forEach((pub) => {
|
||||
const src = pub.source ?? pub.track?.source;
|
||||
if (src === 'screen_share' || src === 'screen_share_audio') {
|
||||
if (pub.track?.mediaStreamTrack) {
|
||||
toStop.push(pub.track.mediaStreamTrack);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
try {
|
||||
await room.localParticipant.setScreenShareEnabled(active, {
|
||||
audio: true,
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to toggle screen share:', e);
|
||||
// User cancelled the picker or permission was denied —
|
||||
// keep local state in sync with whatever actually happened
|
||||
// on the LiveKit side.
|
||||
const published = !!room.localParticipant.getTrackPublication?.(
|
||||
'screen_share',
|
||||
);
|
||||
@@ -933,6 +1072,11 @@ export const VoiceProvider = ({ children }) => {
|
||||
await updateVoiceState({ isScreenSharing: published });
|
||||
return;
|
||||
}
|
||||
if (!active) {
|
||||
for (const mst of toStop) {
|
||||
try { mst.stop(); } catch { /* already stopped */ }
|
||||
}
|
||||
}
|
||||
setIsScreenSharingLocal(active);
|
||||
await updateVoiceState({ isScreenSharing: active });
|
||||
};
|
||||
@@ -1054,54 +1198,109 @@ export const VoiceProvider = ({ children }) => {
|
||||
}, [room, stopRecording]);
|
||||
|
||||
|
||||
// Stable callback so the Provider value doesn't churn on a fresh arrow
|
||||
// function every render.
|
||||
const clearRecordingError = useCallback(() => setRecordingError(null), []);
|
||||
|
||||
// Memoize the provider value so components that subscribe via
|
||||
// `useVoice()` don't re-render on every parent render. Inline object
|
||||
// literals caused every voice-aware component (sidebar, chat header,
|
||||
// user tiles, voice bar) to re-render whenever *anything* upstream
|
||||
// changed — a huge perf hit during active voice sessions.
|
||||
const value = useMemo(() => ({
|
||||
activeChannelId,
|
||||
activeChannelName,
|
||||
connectionState,
|
||||
connectToVoice,
|
||||
disconnectVoice,
|
||||
room,
|
||||
token,
|
||||
voiceStates,
|
||||
activeSpeakers,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
isCameraOn,
|
||||
setCamera,
|
||||
toggleCamera,
|
||||
personallyMutedUsers,
|
||||
togglePersonalMute,
|
||||
isPersonallyMuted,
|
||||
userVolumes,
|
||||
setUserVolume,
|
||||
getUserVolume,
|
||||
serverMute,
|
||||
disconnectUser,
|
||||
isServerMuted,
|
||||
isInAfkChannel,
|
||||
serverSettings,
|
||||
watchingStreamOf,
|
||||
setWatchingStreamOf,
|
||||
switchDevice,
|
||||
globalOutputVolume,
|
||||
setGlobalOutputVolume,
|
||||
isReceivingScreenShareAudio,
|
||||
isReconnecting,
|
||||
connectionQualities,
|
||||
isRecording,
|
||||
recordingStartedAt,
|
||||
recordingSessionId,
|
||||
recordingError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecordingError,
|
||||
}), [
|
||||
activeChannelId,
|
||||
activeChannelName,
|
||||
connectionState,
|
||||
connectToVoice,
|
||||
disconnectVoice,
|
||||
room,
|
||||
token,
|
||||
voiceStates,
|
||||
activeSpeakers,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
isCameraOn,
|
||||
setCamera,
|
||||
toggleCamera,
|
||||
personallyMutedUsers,
|
||||
togglePersonalMute,
|
||||
isPersonallyMuted,
|
||||
userVolumes,
|
||||
setUserVolume,
|
||||
getUserVolume,
|
||||
serverMute,
|
||||
disconnectUser,
|
||||
isServerMuted,
|
||||
isInAfkChannel,
|
||||
serverSettings,
|
||||
watchingStreamOf,
|
||||
setWatchingStreamOf,
|
||||
switchDevice,
|
||||
globalOutputVolume,
|
||||
setGlobalOutputVolume,
|
||||
isReceivingScreenShareAudio,
|
||||
isReconnecting,
|
||||
connectionQualities,
|
||||
isRecording,
|
||||
recordingStartedAt,
|
||||
recordingSessionId,
|
||||
recordingError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecordingError,
|
||||
]);
|
||||
|
||||
return (
|
||||
<VoiceContext.Provider value={{
|
||||
activeChannelId,
|
||||
activeChannelName,
|
||||
connectionState,
|
||||
connectToVoice,
|
||||
disconnectVoice,
|
||||
room,
|
||||
token,
|
||||
voiceStates,
|
||||
activeSpeakers,
|
||||
isMuted,
|
||||
isDeafened,
|
||||
toggleMute,
|
||||
toggleDeafen,
|
||||
isScreenSharing,
|
||||
setScreenSharing,
|
||||
isCameraOn,
|
||||
setCamera,
|
||||
toggleCamera,
|
||||
personallyMutedUsers,
|
||||
togglePersonalMute,
|
||||
isPersonallyMuted,
|
||||
userVolumes,
|
||||
setUserVolume,
|
||||
getUserVolume,
|
||||
serverMute,
|
||||
disconnectUser,
|
||||
isServerMuted,
|
||||
isInAfkChannel,
|
||||
serverSettings,
|
||||
watchingStreamOf,
|
||||
setWatchingStreamOf,
|
||||
switchDevice,
|
||||
globalOutputVolume,
|
||||
setGlobalOutputVolume,
|
||||
isReceivingScreenShareAudio,
|
||||
isReconnecting,
|
||||
connectionQualities,
|
||||
// Voice recording
|
||||
isRecording,
|
||||
recordingStartedAt,
|
||||
recordingSessionId,
|
||||
recordingError,
|
||||
startRecording,
|
||||
stopRecording,
|
||||
clearRecordingError: () => setRecordingError(null),
|
||||
}}>
|
||||
<VoiceContext.Provider value={value}>
|
||||
{children}
|
||||
{room && (
|
||||
<LiveKitRoom
|
||||
|
||||
Reference in New Issue
Block a user