From a635b803edf181ce2551b97f78f7dba29db67e88 Mon Sep 17 00:00:00 2001 From: Kunj Hirapara Date: Tue, 15 Sep 2026 12:53:26 +0530 Subject: [PATCH] fix(meeting): join from the home page, and make host controls act MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two controls that rendered normally and did nothing when pressed. Both had the same shape: the condition gating the action was not the condition that governs whether the action can work. Join Meeting ------------ MeetingCard's button refused to navigate unless a Stream video client was already connected to the page the user was leaving: if (!client) return toast.error("Failed to join meeting. Please try again."); router.push(`/meeting/${callId}`); Navigation does not use that client. /meeting/[id] mounts its own StreamClientProvider and waits for the connection there, which is why pasting the same link into the address bar worked while the button reported failure. On "/" the client is loaded through a dynamic import and only once useUserRole has resolved, so it is null for as long as that chunk takes to fetch and connect — and never arrives at all for a role that does not qualify. joinMeeting now resolves a route from the call id and nothing else. Host controls ------------- Mute all participants and Remove participant were gated on the Stream capabilities, which their creator has, but both handlers opened with: if (!call || !interview) return; An instant meeting writes no interviews row, so getInterviewByStreamCallId resolves to null and MeetingRoom renders with interview === undefined. Every click was discarded silently: no effect, no toast, nothing in the console. The interview is needed to log a host action, never to perform one. The handlers now require only the call, and the audit entry is written best-effort — matching how participant join/leave is already logged, and fixing a second case where a failed log turned a successful mute into an error toast. Both menu and handlers now read one predicate, getHostControlsAvailability, so the render gate and the guard cannot drift apart again. It also hides the menu when the only thing on offer is removal and there is nobody to remove, which previously opened a heading with nothing under it. Also passes an empty pasted meeting id through to the error path instead of dropping it, which was the same silent no-op in MeetingModal. Verified: typecheck clean, 280/280 tests pass (15 new), production build succeeds. --- src/components/ui/MeetingModal.tsx | 8 ++- src/components/ui/MeetingRoom.tsx | 77 +++++++++++++++++------ src/hooks/useMeetingActions.ts | 18 +++++- src/lib/hostControls.test.ts | 99 ++++++++++++++++++++++++++++++ src/lib/hostControls.ts | 62 +++++++++++++++++++ src/lib/meetingNavigation.test.ts | 79 ++++++++++++++++++++++++ src/lib/meetingNavigation.ts | 51 +++++++++++++++ 7 files changed, 369 insertions(+), 25 deletions(-) create mode 100644 src/lib/hostControls.test.ts create mode 100644 src/lib/hostControls.ts create mode 100644 src/lib/meetingNavigation.test.ts create mode 100644 src/lib/meetingNavigation.ts diff --git a/src/components/ui/MeetingModal.tsx b/src/components/ui/MeetingModal.tsx index e790bc3..32caab2 100644 --- a/src/components/ui/MeetingModal.tsx +++ b/src/components/ui/MeetingModal.tsx @@ -28,8 +28,12 @@ function MeetingModal({ const handleStart = async () => { if (isJoinMeeting) { - const meetingId = meetingUrl.split("/").pop(); - if (meetingId) joinMeeting(meetingId); + // Passed through even when empty — a URL ending in "/" yields "", and + // the guard that used to sit here turned that into a button that + // silently did nothing. joinMeeting reports it instead. + const meetingId = meetingUrl.split("/").pop() ?? ""; + + joinMeeting(meetingId); setMeetingUrl(""); onClose(); return; diff --git a/src/components/ui/MeetingRoom.tsx b/src/components/ui/MeetingRoom.tsx index 6d9a4ab..6a5ff60 100644 --- a/src/components/ui/MeetingRoom.tsx +++ b/src/components/ui/MeetingRoom.tsx @@ -58,6 +58,7 @@ import { useProctoring } from "@/hooks/useProctoring"; import { useFullscreenGuard } from "@/hooks/useFullscreenGuard"; import FullscreenGuardOverlay from "./FullscreenGuardOverlay"; import { resolveEnforcement } from "@/lib/proctoring/enforcement"; +import { getHostControlsAvailability } from "@/lib/hostControls"; import IntegrityReport from "@/components/interviews/IntegrityReport"; import { cn, getInterviewEndTimeMs } from "@/lib/utils"; import { getDisplayErrorMessage, logError } from "@/lib/errors"; @@ -203,6 +204,26 @@ function MeetingRoom({ isRecruiter || interview.interviewerIds.includes(currentUser.clerkId) || interview.interviewerIds.includes(localParticipant?.userId ?? ""))); + /** + * The people removal can target: everyone but the local participant, and only + * those Stream has given a userId. + */ + const removableParticipants = useMemo( + () => participants.filter((p) => !p.isLocalParticipant && p.userId), + [participants], + ); + + /** + * One answer to "what may this host do", shared by the menu and the handlers. + * They used to decide separately and disagree — see src/lib/hostControls.ts. + */ + const hostControls = getHostControlsAvailability({ + isHost, + canMuteUsers, + canBlockUsers, + removableParticipantCount: removableParticipants.length, + }); + /** * Integrity monitoring runs for the candidate and nobody else. * @@ -443,21 +464,43 @@ function MeetingRoom({ } /* ── host actions ── */ + + /** + * Best-effort audit trail, matching how participant join/leave is logged + * above: a failed write must never turn a host action that succeeded into an + * error toast, and an ad-hoc call with no interviews row simply has nothing + * to write against. + */ + const logHostAction = (event: { + type: string; + detail: string; + metadata?: string; + }) => { + if (!interview) return; + + void logSessionEvent({ + interviewId: interview._id, + streamCallId: interview.streamCallId, + ...event, + }).catch(() => undefined); + }; + const handleMuteAll = async () => { - if (!call || !interview) return; + // Only `call` is required. An instant meeting has no interviews row, and + // refusing to mute because there is nowhere to file the audit entry is what + // made this button inert — see src/lib/hostControls.ts. + if (!call) return; setHostActionLoading("mute"); try { await call.muteAllUsers("audio"); - await logSessionEvent({ - interviewId: interview._id, - streamCallId: interview.streamCallId, + toast.success("Muted all participants."); + logHostAction({ type: "host.muted_all", detail: "Muted all participants", }); - toast.success("Muted all participants."); } catch (error) { logError("MeetingRoom.handleMuteAll", error, { - interviewId: interview._id, + interviewId: interview?._id, }); toast.error(getDisplayErrorMessage(error, "Unable to mute everyone.")); } finally { @@ -466,21 +509,19 @@ function MeetingRoom({ }; const handleRemoveParticipant = async (userId: string) => { - if (!call || !interview) return; + if (!call) return; setHostActionLoading("remove"); try { await call.blockUser(userId); - await logSessionEvent({ - interviewId: interview._id, - streamCallId: interview.streamCallId, + toast.success("Participant removed from the session."); + logHostAction({ type: "host.removed_participant", detail: userId, metadata: JSON.stringify({ participantId: userId }), }); - toast.success("Participant removed from the session."); } catch (error) { logError("MeetingRoom.handleRemoveParticipant", error, { - interviewId: interview._id, + interviewId: interview?._id, participantId: userId, }); toast.error( @@ -712,7 +753,7 @@ function MeetingRoom({ {/* Host controls. Requires both the app-level role and the Stream capability that authorises the request — the role alone rendered a menu whose every action Stream rejected. */} - {isHost && (canMuteUsers || canBlockUsers) && ( + {hostControls.anyAvailable && (