Skip to content
Closed
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
7 changes: 7 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,13 @@ node-payment-main/
# Sentry Config File
.env.sentry-build-plugin

# Stream call-filter assets, copied out of node_modules by `postinstall`
# (scripts/copy-stream-filter-assets.mjs). Build output, not source: ~32MB of
# WASM and model files that must match the installed SDK version, so committing
# them would only create a way for the two to drift apart.
/public/mediapipe/
/public/nc-models/

# Agent + scratch artefacts that live in the working tree but are not the app.
# `.claude/worktrees/` in particular holds OTHER agents' checkouts — committing
# it drags their in-flight branches into this one. These were swept in once by a
Expand Down
145 changes: 145 additions & 0 deletions __tests__/stream/call-connection-and-quality.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* @jest-environment node
*/

/**
* #1134 — the meeting room collapsed every non-JOINED state into one spinner,
* and offered no control over the video it subscribed to. Both are covered
* here, because both are decisions rather than rendering: which connection
* states are recoverable by the SDK and which need the person to act, and which
* resolution a menu choice actually asks the SFU for.
*/

import { CallingState } from "@stream-io/video-client";

import { describeCallingState } from "../../lib/stream/connection-state";
import {
applyIncomingVideoSetting,
incomingVideoSettingToResolution,
resolveIncomingVideoSetting,
} from "../../lib/stream/incoming-video";

describe("connection states are told apart", () => {
it("says nothing at all once the call is joined", () => {
expect(describeCallingState(CallingState.JOINED)).toBeNull();
});

it.each([CallingState.RECONNECTING, CallingState.MIGRATING])(
"treats %s as transient, with no rejoin action",
(state) => {
const advice = describeCallingState(state);
expect(advice?.tone).toBe("warning");
expect(advice?.canRejoin).toBe(false);
},
);

it("distinguishes being offline from being reconnected", () => {
const offline = describeCallingState(CallingState.OFFLINE);
const reconnecting = describeCallingState(CallingState.RECONNECTING);

expect(offline?.title).not.toBe(reconnecting?.title);
// The SDK recovers to RECONNECTING by itself once the network returns, so
// there is nothing for the person to press.
expect(offline?.canRejoin).toBe(false);
});

it.each([CallingState.RECONNECTING_FAILED, CallingState.LEFT])(
"offers a manual rejoin from %s, the states a Call instance cannot recover from",
(state) => {
const advice = describeCallingState(state);
expect(advice?.tone).toBe("terminal");
expect(advice?.canRejoin).toBe(true);
},
);

it("falls back to the ordinary connecting screen for the cold path", () => {
for (const state of [
CallingState.IDLE,
CallingState.JOINING,
CallingState.RINGING,
CallingState.UNKNOWN,
]) {
const advice = describeCallingState(state);
expect(advice?.tone).toBe("loading");
expect(advice?.canRejoin).toBe(false);
}
});
});

describe("incoming video quality maps to what the SFU is asked for", () => {
it("resolves each named setting to its dimensions", () => {
expect(incomingVideoSettingToResolution("1080p")).toEqual({
width: 1920,
height: 1080,
});
expect(incomingVideoSettingToResolution("720p")).toEqual({
width: 1280,
height: 720,
});
expect(incomingVideoSettingToResolution("480p")).toEqual({
width: 640,
height: 480,
});
expect(incomingVideoSettingToResolution("auto")).toBeUndefined();
expect(incomingVideoSettingToResolution("off")).toBeUndefined();
});

const makeCall = () => ({
setIncomingVideoEnabled: jest.fn(),
setPreferredIncomingVideoResolution: jest.fn(),
});

it("turns incoming video off for audio only", () => {
const call = makeCall();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised
applyIncomingVideoSetting(call as any, "off");

expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(false);
expect(call.setPreferredIncomingVideoResolution).not.toHaveBeenCalled();
});

it("goes back to auto by re-enabling, which drops the cap with it", () => {
const call = makeCall();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised
applyIncomingVideoSetting(call as any, "auto");

// Re-enabling is what undoes both a previous "audio only" and a previous
// manual cap — the SDK clears the subscription overrides on enable.
expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(true);
expect(call.setPreferredIncomingVideoResolution).not.toHaveBeenCalled();
});

it("enables BEFORE capping, so the cap is not wiped by the enable", () => {
const call = makeCall();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- only the two setters are exercised
applyIncomingVideoSetting(call as any, "480p");

expect(call.setIncomingVideoEnabled).toHaveBeenCalledWith(true);
expect(call.setPreferredIncomingVideoResolution).toHaveBeenCalledWith({
width: 640,
height: 480,
});
expect(
call.setIncomingVideoEnabled.mock.invocationCallOrder[0],
).toBeLessThan(
call.setPreferredIncomingVideoResolution.mock.invocationCallOrder[0],
);
});

it("reads the call's own state back into the menu selection", () => {
expect(resolveIncomingVideoSetting({ enabled: false })).toBe("off");
expect(resolveIncomingVideoSetting({ enabled: true })).toBe("auto");
expect(
resolveIncomingVideoSetting({
enabled: true,
preferredResolution: { width: 1280, height: 720 },
}),
).toBe("720p");
expect(
resolveIncomingVideoSetting({
enabled: true,
preferredResolution: { width: 640, height: 480 },
}),
).toBe("480p");
});
});
111 changes: 111 additions & 0 deletions __tests__/stream/client-wait-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/**
* @jest-environment node
*/

/**
* #1134 — the meeting page could sit in a skeleton forever.
*
* `useGetCallById` returns early while the video client is undefined, and
* deliberately so: the provider mounts it lazily, so `undefined` is the normal
* cold-load state and raising an error there produced a "Video client not
* available" flash on every open.
*
* But the effect re-runs only on `[client, callId, rejoinKey]`. If the client
* never arrives — Stream unconfigured, a token fetch that keeps failing, a
* provider that errored out — none of those change, `isCallLoading` stays true,
* and `app/meetings/[id]/page.tsx` renders `MeetingRoomSkeleton` with no error,
* no message and no exit. Someone waiting to be let into a session they paid
* for watches a placeholder animate.
*
* The fix bounds the wait rather than removing it. The bound is the part worth
* testing: it has to outlast the provider's own retry ladder, or it fires while
* the provider is still retrying and would have succeeded — turning a slow
* connect into a reported failure.
*/

import { readFileSync } from "fs";
import { join } from "path";

const hook = readFileSync(
join(process.cwd(), "app/meetings/[id]/hooks/useGetCallById.ts"),
"utf8",
);
const provider = readFileSync(
join(process.cwd(), "providers/StreamProviderImpl.tsx"),
"utf8",
);

/** The ladder StreamProviderImpl actually walks, derived from its own source. */
function providerBackoffMs(): number {
const maxAttempts = Number(
/currentAttempts < (\d+)/.exec(provider)?.[1] ?? NaN,
);
const capMs = Number(
/Math\.min\(1000 \* Math\.pow\(2, attempt\), (\d+)\)/.exec(
provider,
)?.[1] ?? NaN,
);
expect(Number.isFinite(maxAttempts)).toBe(true);
expect(Number.isFinite(capMs)).toBe(true);

// Attempt n sleeps min(1000 * 2^n, cap) before retry n+1, for n = 1..max-1.
let total = 0;
for (let n = 1; n < maxAttempts; n++) {
total += Math.min(1000 * 2 ** n, capMs);
}
return total;
}

describe("the bounded wait for the video client", () => {
it("exists at all", () => {
expect(hook).toContain("CLIENT_WAIT_TIMEOUT_MS");
// The early return must survive — an immediate error is the flash this
// deliberately avoids.
expect(hook).toContain("if (!client) return;");
});

it("clears loading and sets an error when it elapses", () => {
// Both, not just the error: `page.tsx` gates on `isCallLoading` first, so
// an error left underneath a true loading flag renders the same skeleton.
const block = /CLIENT_WAIT_TIMEOUT_MS\);/.exec(hook);
expect(block).not.toBeNull();
const timeoutBody = hook.slice(
hook.indexOf("const timer = setTimeout"),
hook.indexOf("CLIENT_WAIT_TIMEOUT_MS);"),
);
expect(timeoutBody).toContain("setError(");
expect(timeoutBody).toContain("setIsCallLoading(false)");
});

it("outlasts the provider's full retry ladder", () => {
const timeout = Number(
/CLIENT_WAIT_TIMEOUT_MS = ([\d_]+)/
.exec(hook)?.[1]
.replace(/_/g, "") ?? NaN,
);
expect(Number.isFinite(timeout)).toBe(true);

const backoff = providerBackoffMs();
// 30_000ms today. A bound at or under it would report failure while the
// provider was still working — the connect attempts themselves take time
// on top of this, so the margin is the point.
expect(backoff).toBeGreaterThan(0);
expect(timeout).toBeGreaterThan(backoff);
});

it("cancels the timer when the client arrives", () => {
// Otherwise a client that lands at 44s still gets an error at 45s, on top
// of a call that resolved fine.
expect(hook).toContain("return () => clearTimeout(timer)");
expect(hook).toContain("if (client || !callId) return;");
});

it("does not fire once a client is present", () => {
// The guard is the first statement, so a mounted-with-client render never
// schedules anything.
const effectStart = hook.indexOf("if (client || !callId) return;");
const timerStart = hook.indexOf("const timer = setTimeout");
expect(effectStart).toBeGreaterThan(-1);
expect(timerStart).toBeGreaterThan(effectStart);
});
});
101 changes: 101 additions & 0 deletions app/meetings/[id]/components/CallEffectsMenu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"use client";

import { useBackgroundFilters } from "@stream-io/video-react-sdk";
import { Aperture, MicVocal, Wand2 } from "lucide-react";

import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { EffectRow } from "./EffectRow";
import { useNoiseCancellationGate } from "./NoiseCancellationGate";

/**
* Background blur and noise cancellation, in one menu (#1134).
*
* Both are privacy controls before they are polish: a consultation is often
* taken from a kitchen or a shared flat, and until now the only way to keep a
* room out of a session was to turn the camera off altogether.
*/
export function CallEffectsMenu() {
const {
isSupported,
isReady,
isLoading,
backgroundFilter,
applyBackgroundBlurFilter,
disableBackgroundFilter,
} = useBackgroundFilters();

const noiseCancellation = useNoiseCancellationGate();

const blurOn = backgroundFilter === "blur";

return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="rounded-xl bg-zinc-800 p-3 transition-colors hover:bg-zinc-700"
title="Effects"
aria-label="Background and audio effects"
>
<Wand2 className="h-5 w-5 text-white" aria-hidden="true" />
</button>

Check warning on line 46 in app/meetings/[id]/components/CallEffectsMenu.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Add an explicit "type" attribute to this button.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ_8FZzekdcfuurrXNIU&open=AZ_8FZzekdcfuurrXNIU&pullRequest=1157
Comment thread
teetangh marked this conversation as resolved.
</DropdownMenuTrigger>
<DropdownMenuContent
align="center"
className="min-w-[260px] rounded-xl border-zinc-800 bg-zinc-900 p-2"
sideOffset={12}
>
<DropdownMenuLabel className="px-3 py-1.5 text-xs font-normal text-zinc-500">
Effects
</DropdownMenuLabel>
<DropdownMenuSeparator className="bg-zinc-800" />

{isSupported ? (
<EffectRow
icon={Aperture}
label="Blur my background"
hint={
isLoading ? "Applying…" : isReady ? "Hides your room" : "Loading…"

Check warning on line 63 in app/meetings/[id]/components/CallEffectsMenu.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ_8FZzekdcfuurrXNIV&open=AZ_8FZzekdcfuurrXNIV&pullRequest=1157
}
active={blurOn}
disabled={!isReady || isLoading}
loading={isLoading || !isReady}
onToggle={() => {
if (blurOn) disableBackgroundFilter();
else applyBackgroundBlurFilter("high");
}}
/>
) : (
// Safari is excluded by the SDK on purpose — see CallFiltersProvider.
<p className="px-3 py-2.5 text-xs text-zinc-500">
Background blur is not available in this browser. Chrome, Edge and
Firefox support it.
</p>
)}

{noiseCancellation.available && (
<EffectRow
icon={MicVocal}
label="Reduce background noise"
hint={
!noiseCancellation.isOn
? "Filters keyboards, traffic and fans out of your microphone"
: noiseCancellation.isReady
? "On"
: "Starting…"

Check warning on line 90 in app/meetings/[id]/components/CallEffectsMenu.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Extract this nested ternary operation into an independent statement.

See more on https://sonarcloud.io/project/issues?id=Practitionist_familiarise_web&issues=AZ_8FZzekdcfuurrXNIW&open=AZ_8FZzekdcfuurrXNIW&pullRequest=1157
}
active={noiseCancellation.isOn && noiseCancellation.isReady}
disabled={noiseCancellation.isChecking}
loading={noiseCancellation.isOn && !noiseCancellation.isReady}
onToggle={noiseCancellation.toggle}
/>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}
Loading
Loading