Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions src/components/ui/MeetingModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
77 changes: 57 additions & 20 deletions src/components/ui/MeetingRoom.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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.
*
Expand Down Expand Up @@ -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 {
Expand All @@ -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(
Expand Down Expand Up @@ -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 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
Expand All @@ -738,7 +779,7 @@ function MeetingRoom({
</DropdownMenuLabel>
<DropdownMenuSeparator />

{canMuteUsers && (
{hostControls.muteAll && (
<DropdownMenuItem
onClick={handleMuteAll}
disabled={!!hostActionLoading}
Expand All @@ -751,17 +792,13 @@ function MeetingRoom({
</DropdownMenuItem>
)}

{canBlockUsers &&
participants.filter((p) => !p.isLocalParticipant)
.length > 0 && (
{hostControls.removeParticipants && (
<>
<DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs text-muted-foreground">
Remove participant
</DropdownMenuLabel>
{participants
.filter((p) => !p.isLocalParticipant && p.userId)
.map((p) => (
{removableParticipants.map((p) => (
<DropdownMenuItem
key={p.sessionId}
onClick={() =>
Expand Down
18 changes: 15 additions & 3 deletions src/hooks/useMeetingActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useRouter } from "next/navigation";
import { useStreamVideoClient } from "@stream-io/video-react-sdk";
import { toast } from "sonner";

import { resolveJoinTarget } from "@/lib/meetingNavigation";

const useMeetingActions = () => {
const router = useRouter();
const client = useStreamVideoClient();
Expand Down Expand Up @@ -30,10 +32,20 @@ const useMeetingActions = () => {
router.push(`/meeting/${call.id}`);
};

/**
* Navigation only, and deliberately not gated on `client`.
*
* The Stream client belongs to the page being left, not the one being opened:
* /meeting/[id] mounts its own provider and waits for the connection there.
* Requiring it here meant the button failed on the home page while pasting
* the same link worked. See src/lib/meetingNavigation.ts.
*/
const joinMeeting = (callId: string) => {
if (!client)
return toast.error("Failed to join meeting. Please try again.");
router.push(`/meeting/${callId}`);
const target = resolveJoinTarget(callId);

if (!target.ok) return toast.error(target.message);

router.push(target.href);
};
return { createInstantMeeting, joinMeeting };
};
Expand Down
99 changes: 99 additions & 0 deletions src/lib/hostControls.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";

import { getHostControlsAvailability } from "./hostControls.ts";

const host = {
isHost: true,
canMuteUsers: true,
canBlockUsers: true,
removableParticipantCount: 1,
};

describe("getHostControlsAvailability", () => {
it("offers both controls to a host with both capabilities", () => {
assert.deepEqual(getHostControlsAvailability(host), {
muteAll: true,
removeParticipants: true,
anyAvailable: true,
});
});

/**
* The regression this module exists for.
*
* An instant meeting (`useMeetingActions.createInstantMeeting`) creates a
* Stream call under a fresh UUID and writes no `interviews` row, so
* `getInterviewByStreamCallId` resolves to null and MeetingRoom renders with
* `interview === undefined`. The menu was gated on the Stream capabilities —
* which the creator has, because Stream makes them the call's host — while
* both handlers opened with `if (!call || !interview) return`. Every click
* was silently discarded: no effect, no toast, no error.
*
* Availability therefore takes no interview at all. The interview is needed
* to *log* a host action, never to perform one, and a missing audit row is
* not a reason to refuse the thing the button says it does.
*/
it("does not consider whether the call has an interview record", () => {
// There is no interview input to pass. A host of an ad-hoc call with the
// capabilities Stream granted them is offered — and can run — both actions.
assert.equal(getHostControlsAvailability(host).anyAvailable, true);
});

it("offers nothing to a participant who is not a host", () => {
assert.deepEqual(getHostControlsAvailability({ ...host, isHost: false }), {
muteAll: false,
removeParticipants: false,
anyAvailable: false,
});
});

describe("capabilities are per-action", () => {
it("withholds mute-all without MUTE_USERS", () => {
const result = getHostControlsAvailability({
...host,
canMuteUsers: false,
});

assert.equal(result.muteAll, false);
assert.equal(result.removeParticipants, true);
assert.equal(result.anyAvailable, true);
});

it("withholds removal without BLOCK_USERS", () => {
const result = getHostControlsAvailability({
...host,
canBlockUsers: false,
});

assert.equal(result.muteAll, true);
assert.equal(result.removeParticipants, false);
});
});

describe("nobody to remove", () => {
it("withholds the removal section when the host is alone", () => {
// Listing a section with no entries under it is the same empty promise as
// an item that does nothing.
assert.equal(
getHostControlsAvailability({ ...host, removableParticipantCount: 0 })
.removeParticipants,
false,
);
});

it("hides the whole menu when removal was the only thing on offer", () => {
// The trigger used to render on `canMuteUsers || canBlockUsers`, so a
// host who could only block sat in an empty call looking at a menu whose
// sole section was conditional on somebody being there to remove.
const result = getHostControlsAvailability({
isHost: true,
canMuteUsers: false,
canBlockUsers: true,
removableParticipantCount: 0,
});

assert.equal(result.anyAvailable, false);
});
});
});
62 changes: 62 additions & 0 deletions src/lib/hostControls.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* What the host-controls menu may offer, decided in one place.
*
* This exists because the menu and its handlers used to answer that question
* separately and disagreed. The menu rendered on the Stream capabilities
* (`MUTE_USERS`, `BLOCK_USERS`); the handlers additionally required an
* `interviews` row, and returned silently without one:
*
* if (!call || !interview) return;
*
* An instant meeting has no such row, so its creator — whom Stream makes the
* call's host, and therefore grants both capabilities — was shown a full menu
* in which every item was inert. No effect, no toast, no error in the console.
*
* The interview is needed to *log* a host action, never to perform one. A
* missing audit row is not a reason to refuse the thing the button says it
* does, so it is not an input here. Both the render gate and the handlers read
* this one function, which is what stops the two from drifting apart again.
*
* Import-free on purpose so the test runner can reach it (see CLAUDE.md).
*/

export type HostControlsInput = {
/** App-level: the call's creator, an admin/recruiter, or a listed interviewer. */
isHost: boolean;
/** Stream's `OwnCapability.MUTE_USERS` for this participant. */
canMuteUsers: boolean;
/** Stream's `OwnCapability.BLOCK_USERS` for this participant. */
canBlockUsers: boolean;
/** Participants other than the local one — the only people removal can target. */
removableParticipantCount: number;
};

export type HostControlsAvailability = {
muteAll: boolean;
removeParticipants: boolean;
/** Whether the menu is worth rendering at all. */
anyAvailable: boolean;
};

export const getHostControlsAvailability = ({
isHost,
canMuteUsers,
canBlockUsers,
removableParticipantCount,
}: HostControlsInput): HostControlsAvailability => {
const muteAll = isHost && canMuteUsers;

/**
* Removal needs somebody to remove. Without this the menu could open on a
* heading with nothing beneath it, which is the same empty promise as an item
* that does nothing — just quieter.
*/
const removeParticipants =
isHost && canBlockUsers && removableParticipantCount > 0;

return {
muteAll,
removeParticipants,
anyAvailable: muteAll || removeParticipants,
};
};
Loading
Loading