
Cometchat React Calls
- 9 installs
- 70 repo stars
- Updated June 23, 2026
- cometchat/cometchat-skills
Adds voice/video calling to web React apps with the CometChat Calls SDK, dual-SDK init, getRTCToken, call UI components, and browser TURN/STUN handling.
About
Integrates the CometChat Calls SDK into web React apps across Vite, CRA, Next.js, React Router, and Astro. A developer uses it to add incoming/outgoing/ongoing call UI with getUserMedia and TURN/STUN handling.
- Dual-SDK init (Chat SDK + Calls SDK) with getRTCToken
- Incoming/outgoing/ongoing call components and browser TURN/STUN handling
Cometchat React Calls by the numbers
- 9 all-time installs (skills.sh)
- Ranked #1,714 of 2,245 Frontend Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/cometchat/cometchat-skills --skill cometchat-react-callsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 9 |
|---|---|
| repo stars | ★ 70 |
| Last updated | June 23, 2026 |
| Repository | cometchat/cometchat-skills ↗ |
What it does
Adds voice/video calling to web React apps with the CometChat Calls SDK, dual-SDK init, getRTCToken, call UI components, and browser TURN/STUN handling.
Files
⚠️ STOP — mandatory precondition before any code
Before writing one line of code, you MUST resolve `mode = ringing | session`. This decides which entire integration shape you scaffold — they don't share UI, navigation, or surface.
mode | Surface shape | Reference |
|---|---|---|
ringing | CometChatIncomingCall at root + CometChatCallButtons near a user / contact / message header. Recipient's screen rings on incoming call. | references/ringing-integration.md |
session | /meet/:sessionId route (or equivalent) + CometChatCalls.joinSession on a container <div>. No ringing — both parties enter the same session ID. | references/call-session.md |
How to resolve mode (in order):
1. Check `.cometchat/config.json` for `mode` — if set by the cometchat-calls dispatcher's Step 3.0, trust it. 2. Infer from the user's words — see cometchat-calls/SKILL.md Step 3.0 inference table. Confirm in one line: "Got it — setting up Ringing. Say so if you wanted meeting-room URLs instead." 3. If still ambiguous (e.g. user said only "integrate calls" with no qualifier) — ASK before scaffolding. Don't default to ringing. Use this prompt verbatim — preserve the order, the labels, and the descriptions exactly. Do NOT rephrase. Do NOT swap options. Option 1 is "Session"; option 2 is "Ringing":
- question: "What kind of calling experience are you building?"
- header: "Calling mode"
- multiSelect: false
- options (display in this exact order — Session FIRST, Ringing SECOND):
1. label: "Session — meeting / conference room", description: "Multiple users join the same session by ID or link. No ringing. Like Google Meet, Zoom, or a Slack huddle." 2. label: "Ringing — 1:1 or group calls", description: "One user calls another (or a small group). Recipient's device rings; they accept or decline. Like FaceTime or WhatsApp calls."
Strict-order rule: the agent's UI primitive must render option 1 above the option 2. Do not let auto-mode classifiers or your own bias reorder them. Session is shown first because the dashboard team has standardized on this order across CometChat product surfaces; consistency matters more than any subjective "first option" preference.
Do not write `CometChatCallButtons` / `CometChatIncomingCall` / `CometChatOngoingCall` code without a confirmed `mode === "ringing"`. Those components are the kit's implementation of ringing; using them silently locks the integration into ringing-shape even if the user wanted a meeting-room flow.
---
⚠️ Call container — must have non-zero dimensions when joinSession fires
The Calls SDK measures the container <div> synchronously when joinSession runs and throws Container dimensions and number of tiles must be positive if width or height is 0. This is the calls equivalent of the chat-layout flex-shrink trap.
Common bug — `h-full` on a flex child resolves to 0:
// ✗ WRONG — `h-full` is `height: 100%`, but the parent's height is auto
// so 100% of auto = 0. SDK crashes.
<section className="flex-1">
<div ref={containerRef} className="h-full w-full" />
</section>Fix — use a flex chain with `min-h-0` and an explicit fallback:
// ✓ RIGHT — section is a flex column with min-h-0 (so it can shrink), the
// container uses flex-1 to claim remaining space, plus an explicit
// `minHeight` safety net for very short viewports.
<section className="relative flex flex-1 min-h-0">
<div
ref={containerRef}
className="flex-1 w-full"
style={{ minHeight: 400 }}
/>
</section>If you can't use flex (e.g. fixed-height modal), just give the container explicit pixels:
<div
ref={containerRef}
style={{ width: "100%", height: "calc(100vh - 100px)" }}
/>minHeight: 0 matters specifically for parent flex containers that house the call container — without it, a flex-column ancestor whose content overflows defaults to min-height: auto and the call surface gets squeezed to zero. This is the same trap as cometchat-react-patterns's chat-layout rule, applied to calls.
---
⚠️ Next.js / SSR — mandatory bundler config
Both SDKs ship code that breaks Next.js's SSR pass:
@cometchat/chat-sdk-javascriptreferenceswindowat module load time@cometchat/calls-sdk-javascript(v5) imports Node built-ins (fs,path) gated by a runtime check that the bundler still tries to statically resolve
"use client" alone does NOT fix this — Next.js evaluates client components during the initial SSR pass for hydration. You need to defer the SDK imports so they only execute in the browser.
For Next.js (App Router or Pages Router), apply ALL of these:
1. Switch dev/build to webpack in package.json scripts (Turbopack's fs/path aliasing is fragile in Next 16):
{
"scripts": {
"dev": "next dev --webpack",
"build": "next build --webpack",
"start": "next start"
}
}2. Add a webpack `fs` fallback in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
webpack: (config, { isServer }) => {
if (!isServer) {
config.resolve = config.resolve || {};
config.resolve.fallback = {
...(config.resolve.fallback || {}),
fs: false,
path: false,
};
}
return config;
},
};
export default nextConfig;3. Wrap the CometChatProvider in `next/dynamic({ ssr: false })` — create a small client wrapper and use it from a server-component layout:
// app/_components/CometChatGate.tsx
"use client";
import dynamic from "next/dynamic";
import type { ReactNode } from "react";
const CometChatProvider = dynamic(
() => import("@/cometchat/CometChatProvider").then((m) => m.CometChatProvider),
{ ssr: false, loading: () => <div>Loading…</div> },
);
export function CometChatGate({ children }: { children: ReactNode }) {
return <CometChatProvider>{children}</CometChatProvider>;
}⚠️ Provider placement for Ringing mode — mount at app root, not on a sub-route layout. If the CometChatProvider registers a global CallListener for incoming calls (which it should — see "Ringing mode listener" below), it MUST be mounted in the root layout (app/layout.tsx). Mounting it on a sub-route layout like app/meet/layout.tsx means the listener is only armed while the user is browsing under that sub-route — incoming calls land silently when they're on the home page or any other route, and the caller sees a timeout/rejection.
// app/layout.tsx (server component, root layout)
import { CometChatGate } from "./_components/CometChatGate";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html><body>
<CometChatGate>{children}</CometChatGate>
</body></html>
);
}For Session-only mode (no ringing — both parties navigate to a shared /meet/:id URL), the sub-route layout is fine — listener isn't load-bearing.
Ringing mode listener — inside CometChatProvider, after login:
const { CometChat } = await import("@cometchat/chat-sdk-javascript");
CometChat.addCallListener("ringing-listener", new CometChat.CallListener({
onIncomingCallReceived: async (call: any) => {
const accepted = await CometChat.acceptCall(call.getSessionId());
router.push(`/meet/${encodeURIComponent(accepted.getSessionId())}`);
},
onOutgoingCallAccepted: (call: any) => {
router.push(`/meet/${encodeURIComponent(call.getSessionId())}`);
},
onOutgoingCallRejected: (call: any) => { /* show toast */ },
onIncomingCallCancelled: (call: any) => { /* dismiss any UI */ },
}));The /meet/:sessionId page handles joinSession. Validated end-to-end against Pixel 3 V6 Android peer on 2026-05-12.
4. Lazy-load the SDKs inside `init.ts` — replace top-level static imports with await import(...) inside the init/login functions. The provider is gated by step 3, but init.ts is shared with any page that uses CometChatCalls directly, so belt-and-braces it:
let chatModule: typeof import("@cometchat/chat-sdk-javascript") | null = null;
let callsModule: typeof import("@cometchat/calls-sdk-javascript") | null = null;
async function loadSdks() {
if (!chatModule) chatModule = await import("@cometchat/chat-sdk-javascript");
if (!callsModule) callsModule = await import("@cometchat/calls-sdk-javascript");
return { CometChat: chatModule.CometChat, CometChatCalls: callsModule.CometChatCalls };
}5. No top-level SDK imports in any page that's reachable via App Router routing. Inside useEffect handlers, dynamic-import the SDK:
useEffect(() => {
let CallsSdk: typeof import("@cometchat/calls-sdk-javascript").CometChatCalls | null = null;
(async () => {
const mod = await import("@cometchat/calls-sdk-javascript");
CallsSdk = mod.CometChatCalls;
// ... use CallsSdk
})();
return () => {
try { CallsSdk?.leaveSession(); } catch { /* noop */ }
};
}, []);Skipping any of these reproduces the failure mode: the route 500s with either Module not found: Can't resolve 'fs' or ReferenceError: window is not defined. Verified empirically against Next.js 16.2.6 + Calls SDK 5.0.0-beta.2.
For Vite / React Router / Astro, none of this applies — those bundlers don't pre-evaluate client modules.
---
Purpose
Production-grade voice + video calling for React-family web apps. Loaded by cometchat-calls when framework is one of reactjs, nextjs, react-router, or astro. Operates in two modes:
- Standalone — calls is the product.
@cometchat/chat-sdk-javascript(signaling) +@cometchat/calls-sdk-javascript(WebRTC) + a small set of UI Kit call components. NoCometChatConversations/CometChatMessageList/ etc. - Additive — calls layered onto an existing CometChat React UI Kit integration. Adds call buttons inline, mounts
CometChatIncomingCallat app root.
Read these other skills first:
cometchat-calls— dispatcher (modes, hard rules, anti-patterns)cometchat-core— Chat SDK init, login, env-var prefix per framework, SSR safety- Framework-specific patterns:
cometchat-react-patterns/cometchat-nextjs-patterns/cometchat-react-router-patterns/cometchat-astro-patterns
Ground truth:
- SDK source —
~/Downloads/calls-sdk/calls-sdk-javascript-5/package/ - Sample apps —
~/Downloads/calls-sdk/calls-sdk-javascript-5/sample-apps/{react,vue,angular,svelte,ionic}/ - Public docs — https://www.cometchat.com/docs/calls/javascript/overview
---
1. The seven hard rules — web specialization
1.1 Dual-SDK contract
@cometchat/chat-sdk-javascript for ringing; @cometchat/calls-sdk-javascript for the WebRTC session. They are separate npm packages.
// ✓ RIGHT — initiate ringing (Chat SDK)
import { CometChat } from "@cometchat/chat-sdk-javascript";
const outgoing = new CometChat.Call(receiverUid, CometChat.CALL_TYPE.VIDEO, CometChat.RECEIVER_TYPE.USER);
const initiated = await CometChat.initiateCall(outgoing);
// initiated.getSessionId() — the ID the Calls SDK will join// ✓ RIGHT — join WebRTC session (Calls SDK v5)
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
// v5 — plain SessionSettings object, no Builder
const sessionSettings = {
sessionType: "VIDEO", // or "VOICE"
layout: "TILE",
};
// v5 — generateToken takes ONLY sessionId (Calls SDK has its own auth state
// after CometChatCalls.login(); no authToken arg needed).
const tokenRes = await CometChatCalls.generateToken(sessionId);
// htmlElement is REQUIRED — pass the DOM container the SDK should draw into
const container = document.getElementById("ongoing-call-root")!;
const result = await CometChatCalls.joinSession(tokenRes.token, sessionSettings, container);
if (result?.error) {
console.error("joinSession failed:", result.error);
}The two-Call-classes problem from Android does NOT exist on JS — there's only one CometChat.Call constructor. But the dual-SDK split still trips up agents trained on the chat-only docs.
1.2 VoIP push — N/A on web (browsers don't have VoIP push)
The mandatory-VoIP-push rule from mobile families does not apply to web. Browsers cannot ring a closed tab. The standalone-mode equivalent is Web Push notifications (Service Worker + Notification API + push subscriptions) — useful for nudging the user to a tab where the call screen is open, but they do not bypass tab/page-load.
The skill scaffolds Web Push as an opt-in (asks user); it is not strictly required. Production calls UX on web typically pairs with email/SMS fallback for missed calls, not VoIP.
1.3 Lifecycle — getUserMedia cleanup
Web's equivalent of Android's foreground-service correctness is MediaStream track cleanup. Browsers don't release the camera/mic until tracks are explicitly stopped. The kit handles this for <CometChatOngoingCall />, but custom WebRTC surfaces (Section 4) must do:
function endCall() {
// 1. End the Calls SDK session — releases the kit's internal stream
CometChatCalls.leaveSession(); // v5 — was endSession() in v4 (still works as a deprecated shim)
// 2. If you grabbed a custom MediaStream (preview, screen-share), stop tracks
customStream?.getTracks().forEach(t => t.stop());
customStream = null;
// 3. Detach video elements
if (videoEl.current) videoEl.current.srcObject = null;
}Skipping this leaves the camera light on until the tab is closed. Same canonical bug as iOS rule 1.5.
1.4 Server-minted auth tokens for production
In v5 the Calls SDK has its own login step — it no longer piggybacks on the Chat SDK's auth context implicitly. After CometChat.login() resolves on the chat side, call `CometChatCalls.login(uid, apiKey)` for dev or `CometChatCalls.loginWithAuthToken(authToken)` for production. The auth token is the same token your backend mints via the CometChat Create-Auth-Token API; the Calls SDK and Chat SDK accept it interchangeably.
// Dev
await CometChatCalls.login(uid, import.meta.env.VITE_COMETCHAT_API_KEY);
// Production (server-minted token)
await CometChatCalls.loginWithAuthToken(authTokenFromBackend);cometchat-production (web) covers the token-endpoint pattern.
1.5 Hangup cleanup — see rule 1.3
1.6 Permissions — getUserMedia prompts
The browser handles the runtime permission prompt automatically when the Calls SDK calls getUserMedia. The integration must:
- Surface a
try/catcharoundstartSessionto handleNotAllowedError(user denied) - Surface
NotFoundError(no camera/mic on device — common on desktops with no webcam) - Render a clear in-app explanation BEFORE the browser prompts, so users know what they're agreeing to (browsers ignore this in autoplay/iframe contexts but it improves grant rates)
There are no manifest-level permission declarations on web. HTTPS is required — the skill detects localhost (allowed) vs other origins (must be HTTPS) and warns if the dev server is HTTP.
1.7 IncomingCall mounted at app root
<CometChatIncomingCall /> (additive mode) or a Service-Worker-driven web-push handler (standalone mode) must mount above the route boundary so calls fire on every page.
// app/layout.tsx (Next.js App Router) or App.tsx (Vite/CRA)
<CometChatProvider>
<CometChatIncomingCall /> {/* renders nothing when no call active; listens app-wide */}
<Routes>...</Routes>
</CometChatProvider>Mounting it inside a route component means it disappears on navigation — calls only ring on the screen where it's mounted. That's the canonical "calls don't work" bug on web.
---
2. Setup
Install
Calls SDK v5.0.0 stable shipped but the npm latest dist-tag still points at v4.2.6 (legacy). Always pin to `@5` (or a specific `^5.0.0` version) — npm install @cometchat/calls-sdk-javascript (no tag) resolves to v4.2.6, which is the previous generation. @beta is also published but pins to an older 5.0.0-beta.2 — prefer @5 to get the latest stable 5.x.
# v5 stable (current — pulls 5.0.0 or newer 5.x)
npm install @cometchat/chat-sdk-javascript @cometchat/calls-sdk-javascript@5
# additive mode: @cometchat/chat-uikit-react is already installedThe kit (@cometchat/chat-uikit-react@^6.x) was built against the v4 calls API but Calls SDK v5 ships v4 deprecated-method shims that delegate to v5 implementations — so the kit's CometChatCallButtons / CometChatIncomingCall / CometChatOngoingCall keep working when you swap v4 for v5. Custom call surfaces should use v5 APIs directly.
Init order (web — v5)
// cometchat/init.ts
import { CometChat } from "@cometchat/chat-sdk-javascript";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
let initialized = false;
export async function initCometChat() {
if (initialized) return;
// 1. Chat SDK init (signaling)
const appSettings = new CometChat.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(import.meta.env.VITE_COMETCHAT_REGION) // adjust for framework
.build();
await CometChat.init(import.meta.env.VITE_COMETCHAT_APP_ID, appSettings);
// 2. Calls SDK init (WebRTC) — v5 takes a plain object and returns {success, error}
const callsInit = await CometChatCalls.init({
appId: import.meta.env.VITE_COMETCHAT_APP_ID,
region: import.meta.env.VITE_COMETCHAT_REGION,
});
if (!callsInit?.success) {
throw new Error(`CometChatCalls.init failed: ${JSON.stringify(callsInit?.error)}`);
}
initialized = true;
}
// After your existing CometChat.login(uid, apiKey) call, login the Calls SDK too.
// In v5 the Calls SDK has its own auth state; this step is mandatory.
export async function loginCometChat(uid: string) {
await CometChat.login(uid, import.meta.env.VITE_COMETCHAT_AUTH_KEY);
// v5 — Calls SDK login. Either form is fine; loginWithAuthToken is for production.
if (!CometChatCalls.getLoggedInUser()) {
await CometChatCalls.login(uid, import.meta.env.VITE_COMETCHAT_API_KEY);
// OR: await CometChatCalls.loginWithAuthToken(serverMintedToken);
}
}The module-level initialized flag prevents StrictMode double-init in React 18+ dev mode. The getLoggedInUser() guard prevents re-login on hot reload.
Framework-specific env prefixes (already covered by cometchat-core)
| Framework | Env prefix |
|---|---|
| Vite (reactjs / react-router) | VITE_ |
| CRA | REACT_APP_ |
| Next.js | NEXT_PUBLIC_ |
| Astro | PUBLIC_ |
SSR safety
CometChat Calls SDK is browser-only — window, MediaStream, RTCPeerConnection, navigator.mediaDevices. Calls components must NOT render server-side:
- Next.js App Router: add
"use client"to the file containing call components - Next.js Pages Router: dynamic-import with
ssr: false - React Router: lazy +
<Suspense>+if (typeof window === "undefined") return nullguard in the component - Astro:
client:only="react"on the call component island
---
3. Components catalog
Calls SDK primitives — v5 (used in standalone or wherever you build custom UI)
| Class / function | Purpose |
|---|---|
CometChatCalls.init({ appId, region }) | One-time init. Returns Promise<{ success, error }> — check .success. |
CometChatCalls.login(uid, apiKey) | Dev-mode login. Returns the logged-in User. |
CometChatCalls.loginWithAuthToken(authToken) | Production login with server-minted token. |
CometChatCalls.getLoggedInUser() | Returns a plain { uid, name, avatar?, status?, ... } object or null. Access .uid as a PROPERTY (not .getUid() — that method belongs to the Chat SDK's User class, which session-only code does not import). Use to guard against double-login: if (existing && existing.uid === uid) return existing; |
CometChatCalls.logout() | Clears Calls SDK auth state. |
CometChatCalls.generateToken(sessionId) | Mint a session-scoped RTC token. Single arg — auth is implicit after login(). |
CometChatCalls.joinSession(callToken, sessionSettings, htmlElement) | Join the WebRTC session — htmlElement is required. Returns { data, error }. |
CometChatCalls.leaveSession() | End + cleanup. Returns void. |
CometChatCalls.addEventListener(eventName, handler) | Granular event subscription — replaces v4's monolithic OngoingCallListener. Returns an unsubscribe function. |
CometChatCalls.setLayout(layout) | "TILE" / "SIDEBAR" / "SPOTLIGHT". Per-participant. |
CometChatCalls.constants.LAYOUT | Layout enum for type-safety. |
v4 → v5 method mapping (the deprecated v4 method names below all still work in v5 as shims that delegate to v5 implementations — your kit's v6 code is unaffected):
| v4 (deprecated) | v5 |
|---|---|
init(new CallAppSettingsBuilder().setAppId(...).build()) | init({ appId, region }) |
generateToken(sid, authToken) | generateToken(sid) (after login()) |
startSession(token, settings, el) | joinSession(token, settings, el) |
endSession() | leaveSession() |
setMode(mode) | setLayout(layout) |
OngoingCallListener (single object) | addEventListener(name, handler) (granular) |
enterPIPMode() | enablePictureInPictureLayout() |
See references/migration-v4-to-v5.md for the full migration guide.
UI Kit components (additive mode — @cometchat/chat-uikit-react)
| Component | Purpose |
|---|---|
<CometChatCallButtons user={u} /> | Voice + video icon row, drop into any header |
<CometChatIncomingCall /> | Root-mounted; renders nothing when no call active |
<CometChatOutgoingCall /> | Auto-mounted by IncomingCall on initiateCall |
<CometChatOngoingCall /> | Active call view; hosts the WebRTC element |
<CometChatCallLogs onItemClick={fn} /> | Paginated history |
In standalone mode, you can compose just <CometChatOngoingCall /> + <CometChatCallLogs /> from the UI Kit even without using CometChatConversations etc. — the kit's calls components don't depend on its conversation components.
---
4. Standalone integration
When product === "voice-video" and there is no existing chat UI integration.
Split by calling mode — these are two different shapes:
4a. Standalone — Session mode (meeting-room UX, no ringing)
Calls SDK ONLY. NO Chat SDK. Matches the upstream sample at ~/Downloads/calls-sdk/calls-sdk-javascript-5/sample-apps/cometchat-calls-sample-app-react/. The skill scaffolds:
1. `cometchat/init.ts` — CometChatCalls.init({ appId, region, authKey }) ONLY. No CometChat.init, no CometChat.login. Pass authKey at init time so subsequent CometChatCalls.login(uid) calls need no second arg. 2. `cometchat/CometChatProvider.tsx` — Runs Calls SDK init on mount, exposes loggedInUser via CometChatCalls.getLoggedInUser(), gates children on success. 3. `pages/Home.tsx` — UID picker (dev mode) + "Start meeting" (mints UUID, navigates to /meet/:id) + "Join meeting" (paste sessionId). 4. `pages/CallRoom.tsx` — /meet/:sessionId route. Container is position: fixed; width: 100vw; height: 100vh. CometChatCalls.joinSession(token, {}, container) with empty settings. See references/call-session.md for the canonical pattern. 5. HTTPS check — warns if dev server is HTTP non-localhost.
Why no Chat SDK: session mode never touches the Chat SDK call entity. Initializing both SDKs adds two failure modes (Chat init, Chat login race) for zero benefit. The upstream sample confirms this — it never imports @cometchat/chat-sdk-javascript.
4b. Standalone — Ringing mode (CallButtons + Incoming/Outgoing/Ongoing kit components)
Dual-SDK: Chat SDK signaling channel + Calls SDK media channel. The skill scaffolds:
1. `cometchat/init.ts` — Chat SDK + Calls SDK init (sequential), module-level guard. 2. `cometchat/CometChatProvider.tsx` — React provider, runs init+login on mount, gates children on success. 3. `components/CallButton.tsx` — Voice + video buttons next to a user (your existing user listing / profile page). 4. `/calls` route or screen — Renders <CometChatCallLogs /> for history. (Path depends on framework — app/calls/page.tsx for Next.js App Router, routes/calls.tsx for React Router, etc.) 5. `OngoingCallView.tsx` — Custom WebRTC view OR delegates to <CometChatOngoingCall />. Implements rule 1.3 cleanup. 6. Provider mounts `<CometChatIncomingCall />` at the layout root (rule 1.7). 7. Optional Web Push — Service Worker registration + push subscription endpoint, if the user opts in. 8. HTTPS check — warns if dev server is HTTP non-localhost.
5. Additive integration
When cometchat-core integration already exists. The skill:
1. Adds @cometchat/calls-sdk-javascript@5 to package.json (v5.0.0 stable — see Install section). 2. Patches cometchat/init.ts to call CometChatCalls.init({...}) after CometChat.init AND to call CometChatCalls.login(uid, apiKey) after CometChat.login (v5 — separate auth). 3. Mounts <CometChatIncomingCall /> at the layout root next to existing components (rule 1.7). 4. Adds <CometChatCallButtons user={user} /> inline on selected screens — usually inside <CometChatMessageHeader /> (the kit auto-renders it there if a user prop is set). 5. Adds a /calls route for <CometChatCallLogs /> if the user picked the "dedicated route" option.
6. Anti-patterns
1. Mounting `<CometChatIncomingCall />` inside a route component. Disappears on navigation. Mount above the route boundary in the layout (rule 1.7). 2. Initializing both SDKs in parallel. CometChatCalls.init requires the Chat SDK's app-id context; calling them with Promise.all causes intermittent "auth context null" errors. Sequence: chat init → calls init. 3. Skipping the `initialized` guard. React StrictMode renders effects twice in dev — without the guard, you get duplicate listeners and double-init warnings. 4. Forgetting `getTracks().forEach(t => t.stop())` on custom streams. Camera light stays on until tab close. Rule 1.3. 5. Embedding `<CometChatOngoingCall />` in an `<iframe>` without `allow="camera; microphone"`. Browsers silently deny getUserMedia. The skill detects iframe contexts and writes the allow list. 6. Calling `joinSession` (v5) before `CometChatCalls.login()` resolves. generateToken 401s — no Calls SDK auth state. Sequence: CometChatCalls.init → CometChatCalls.login → generateToken → joinSession. 7. Installing `@cometchat/calls-sdk-javascript` without a version pin. Resolves to v4.2.6 (the latest dist-tag) instead of v5.0.0. The kit's v4 deprecated-method shims live INSIDE v5 — picking up plain v4 means you don't get them. Always @5 (recommended) or pin a specific ^5.0.0 version. @beta is also valid but pins to an older 5.0.0-beta.2. 7. Running over HTTP (non-localhost). getUserMedia returns NotAllowedError. The skill warns; user must use HTTPS or localhost.
7. Verification checklist
Static:
- [ ] Both
@cometchat/chat-sdk-javascriptand@cometchat/calls-sdk-javascript@5(v5 stable) inpackage.json - [ ]
CometChatCalls.login(uid, apiKey)is called afterCometChat.login(v5 separate auth) - [ ] Init order: chat init → calls init (sequential, not parallel)
- [ ]
<CometChatIncomingCall />mounted at layout root (additive mode) - [ ] Cleanup path stops MediaStream tracks + ends Calls SDK session
- [ ] Framework-correct SSR guard (
"use client"/ssr: false/client:only) - [ ] Env vars use the framework-correct prefix
- [ ] Module-level
initializedflag for StrictMode safety
Runtime (browser):
- [ ] Outgoing call connects, two-way audio + video
- [ ] Incoming call rings on a separate page within the same SPA
- [ ] Camera light off within 2 seconds of hangup
- [ ] Tab refresh during call cleanly disconnects (no orphaned session)
- [ ] HTTPS or localhost only —
getUserMediaworks - [ ] Multi-tab: closing one tab doesn't end the call in another tab
8. Pointers
cometchat-core— provider pattern, init guard, login ordercometchat-components— full UI Kit catalog (additive mode)cometchat-{nextjs,react,react-router,astro}-patterns— framework-specific SSR guards, route placementcometchat-production— server-minted tokens, securitycometchat-troubleshooting— common web failure modes (HTTPS, iframe permissions, StrictMode double-init)
Adding calls to an existing chat integration (web)
You already have CometChat chat working (@cometchat/chat-sdk-javascript + @cometchat/chat-uikit-react). This guide adds calling on top with minimum disruption.
Read first: cometchat-react-calls/SKILL.md — the seven hard rules. They apply whether you're starting fresh or migrating.
---
Pre-flight
Confirm what's already in place:
# Should show chat SDK + UI Kit
grep -E '"@cometchat/(chat-sdk|chat-uikit)' package.json
# Should NOT yet show calls SDK
grep -E '"@cometchat/calls-sdk' package.jsonIf you already have @cometchat/calls-sdk-javascript installed, you're not migrating — re-running /cometchat-calls will integrate calls features (recording, share-invite, etc.) one at a time.
---
Step 1 — Install the calls SDK
npm install @cometchat/calls-sdk-javascript@5This is a pure additive change. The chat SDK is unaffected.
---
Step 2 — Add calls init AFTER chat init
The seven hard rules: chat must init before calls. If your existing init looks like:
// src/cometchat-init.ts
import { CometChat } from "@cometchat/chat-sdk-javascript";
const settings = new CometChat.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(REGION)
.build();
await CometChat.init(APP_ID, settings);Append the calls init:
import { CometChat } from "@cometchat/chat-sdk-javascript";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
const settings = new CometChat.AppSettingsBuilder()
.subscribePresenceForAllUsers()
.setRegion(REGION)
.build();
await CometChat.init(APP_ID, settings);
// NEW — calls init AFTER chat init
await CometChatCalls.init({ appId: APP_ID, region: REGION });---
Step 3 — Add Calls SDK login after chat login
Wherever you call CometChat.login(uid, authKey) (or your auth-token equivalent), add:
const user = await CometChat.login(uid, authKey);
const authToken = user.getAuthToken();
await CometChatCalls.login(authToken);If you use server-minted auth tokens (production hygiene — see cometchat-production), the same auth token works for both SDKs.
---
Step 4 — Mount IncomingCall at the app root
Add CometChatIncomingCall somewhere that's always rendered (the root layout, app shell, or a portal):
// src/App.tsx (or your root layout)
import { CometChatIncomingCall } from "@cometchat/chat-uikit-react";
function App() {
return (
<>
<YourExistingRoutes />
<CometChatIncomingCall />
</>
);
}This component listens for incoming-call events and renders the accept/reject UI. Without it, your users won't see incoming calls.
---
Step 5 — Add call buttons to existing chat surfaces
If you're rendering CometChatMessageHeader (the per-conversation header), call buttons appear automatically once the calls SDK is initialized. You don't add anything for this — the UI Kit detects the calls SDK and wires the buttons.
Verify by opening any 1:1 chat — voice + video icons should appear in the header.
---
Step 6 — (Optional) Custom call surface
If you want a custom call experience instead of the kit's default UI, see cometchat-react-calls/SKILL.md Step 4 for the dispatcher pattern.
---
Verification checklist
- [ ]
@cometchat/calls-sdk-javascript@^5in package.json - [ ] Calls SDK init runs AFTER chat SDK init
- [ ]
CometChatCalls.login(authToken)runs AFTERCometChat.login - [ ]
CometChatIncomingCallmounted at app root - [ ] Call buttons visible in CometChatMessageHeader
- [ ] Run
cometchat verify --calls— should pass all 20 checks - [ ] Smoke test: 2 tabs (different users), call from one, ringing in the other
---
Common pitfalls when migrating
1. Calls init before chat init. Calls SDK throws "App not initialized." Order matters. 2. Forgetting `CometChatCalls.login`. Token-mint endpoints fail with 401. 3. Mounting `CometChatIncomingCall` per-route. Missed when user is on a non-chat route. Mount at root. 4. Hand-rolling call UI before installing the SDK. Easy to skip the seven hard rules. Use the kit's components first; customize after a working baseline.
---
Pointers
cometchat-react-calls/SKILL.md— full architecture + seven hard rulescometchat-react-calls/references/recording.mdetc. — feature-specific add-onscometchat verify --calls— automated check (seecometchat-cli)
Call layouts on web (React)
CometChat Calls SDK ships three layout modes: TILE (grid), SIDEBAR (main speaker + filmstrip), SPOTLIGHT (active speaker hero). Each participant picks their own layout — the choice is local, not session-wide.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/call-layouts
---
Available layouts
| Layout | What it shows | Best for |
|---|---|---|
TILE | Equally-sized grid tiles | Group meetings, equal participation |
SIDEBAR | Main speaker, others in sidebar | Presentations, webinars |
SPOTLIGHT | Hero active speaker, small thumbnails | 1:1 calls, focused discussions |
Default layout: kit picks based on participant count (SPOTLIGHT for 1:1, TILE for groups). Override only when your UX requires it.
---
Set initial layout
Pass layout into call settings when joining:
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
const callSettings = {
layout: "TILE", // or "SIDEBAR" | "SPOTLIGHT"
// ... rest of your CallSettings
};
await CometChatCalls.joinSession(callToken, callSettings, containerEl);Use the constants instead of string literals to avoid typos:
const layouts = CometChatCalls.constants.LAYOUT;
// layouts.TILE === "TILE"
// layouts.SIDEBAR === "SIDEBAR"
// layouts.SPOTLIGHT === "SPOTLIGHT"---
Change layout during a call
function LayoutSwitcher() {
const [layout, setLayout] = useState<"TILE" | "SIDEBAR" | "SPOTLIGHT">("TILE");
function handleChange(next: typeof layout) {
CometChatCalls.setLayout(next);
setLayout(next);
}
return (
<div role="radiogroup" aria-label="Call layout">
{(["TILE", "SIDEBAR", "SPOTLIGHT"] as const).map((opt) => (
<button
key={opt}
role="radio"
aria-checked={layout === opt}
onClick={() => handleChange(opt)}
>
{opt}
</button>
))}
</div>
);
}setLayout is local — only the caller's view changes. Other participants stay on whatever layout they had.
---
Listen for layout changes
The kit fires onCallLayoutChanged when the user picks a different layout via the kit's built-in switcher. Listen so your custom controls stay in sync:
useEffect(() => {
const handler = (newLayout: string) => {
setLayout(newLayout as typeof layout);
};
CometChatCalls.addEventListener("onCallLayoutChanged", handler);
return () => {
CometChatCalls.removeEventListener("onCallLayoutChanged", handler);
};
}, []);---
Hide the kit's layout switcher
If you ship your own layout UI:
const callSettings = {
hideChangeLayoutButton: true,
// ... rest
};Or to suppress layout changing entirely (locked layout):
// hide the button AND don't ship your own switcher
const callSettings = { hideChangeLayoutButton: true, layout: "TILE" };---
When to lock a layout
Lock to SPOTLIGHT:
- Telehealth provider/patient calls (focus on faces)
- 1:1 sales/demo calls
- Coaching sessions
Lock to TILE:
- Standups, retros, team meetings (everyone equally visible)
- Classroom or training calls
Lock to SIDEBAR:
- Webinars (presenter dominant)
- Live broadcasts with fixed roles
For general meetings: don't lock. Let users pick.
---
Anti-patterns
1. Calling `setLayout` before `joinSession` resolves. Throws — the call surface isn't bound yet. Set the initial layout via callSettings.layout instead. 2. Storing layout in URL or shared state. Layout is per-participant local. Sharing it via URL/Firestore causes layout flicker as multiple peers fight to set it. 3. String literals everywhere. Use CometChatCalls.constants.LAYOUT so typos surface at autocomplete time, not runtime. 4. Forgetting `removeEventListener` on unmount. Listener accumulates across calls → setLayout fires N times. 5. Custom switcher AND kit's switcher both visible. Confusing. Set hideChangeLayoutButton: true if you ship your own.
---
Verification checklist
- [ ] Initial layout passed via
callSettings.layout - [ ]
setLayoutonly called afterjoinSessionresolves - [ ]
onCallLayoutChangedlistener cleaned up on unmount - [ ] Layout constants used instead of string literals
- [ ] Smoke test: 3-person call, each participant picks different layout, nobody else's view changes
---
Pointers
cometchat-react-calls/SKILL.md— call surface architecturecometchat-react-calls/references/recording.md— sister cross-cutting concerncometchat-react-calls/references/in-call-chat.md— chat panel sits beside layout- Canonical docs: https://www.cometchat.com/docs/calls/javascript/call-layouts
Call session — joinSession with no ringing (web)
The Call Session flow uses only the Calls SDK. Both parties enter a known sessionId directly via CometChatCalls.joinSession — no Chat SDK call entity, no ringing, no incoming-call notification. This is the right pattern for scheduled meetings, conference rooms, and shareable meeting links.
Three calling modes — pick the right one:
| Mode | Driver | When to use |
|---|---|---|
| Standard | UI Kit (CometChatCallButtons) | 80% case — chat-driven calls with prebuilt UI |
Ringing (see ringing-integration.md) | Chat SDK call entity + Calls SDK session | Custom incoming/outgoing call UI on top of CometChat signaling |
| Call Session (this doc) | Calls SDK joinSession directly | Meeting-room URLs, scheduled calls, no ringing |
Canonical docs: https://www.cometchat.com/docs/calls/javascript/join-session
---
When this is the right mode
- "Join meeting" link sent in calendar invite
- Conference rooms — same sessionId for repeated meetings
- Webinars / town halls (combined with Tier 4 broadcast use-case)
- Team huddles — sessionId derived from the team channel ID
- Office hours — fixed sessionId, anyone can join during hours
If your UX has one party initiating and another being notified, you want Ringing, not Call Session.
---
Hard rules
1. All participants need the same `sessionId`. Generate it server-side; share via your app's existing channels (chat custom message, email link, push notification). 2. `generateToken(sessionId)` is per-user, per-session. Each participant generates their own token; the token embeds the user's identity. Don't share tokens between users. 3. The container element must exist before `joinSession` runs AND must have measurable, viewport-anchored dimensions. Use position: fixed; top: 0; left: 0; width: 100vw; height: 100vh (matches the upstream sample), OR explicit pixel dimensions. Flex-derived sizing inside nested layouts has produced "container exists but iframe renders at 0×0 → no video" in customer integrations. The SDK measures the container at the moment joinSession is called. 4. `CometChatCalls.login(uid)` must run before `generateToken`. Otherwise ERROR_AUTH_TOKEN_MISSING. Pass authKey once at CometChatCalls.init({appId, region, authKey}) time so login(uid) needs no second arg — matches the sample app pattern. 5. `joinSession` returns an object with `error` — check it. Don't assume a resolved Promise = success. 6. No `CometChat.endCall` needed — Call Session has no chat-side call entity. Just CometChatCalls.leaveSession. 7. For session-only integrations, the Chat SDK is OPTIONAL. The upstream sample app uses ONLY the Calls SDK — no CometChat.init / CometChat.login anywhere. Keep the dual-SDK contract for additive (chat + calls) integrations; drop the Chat SDK entirely for standalone session-mode (calls-only meeting-room UX). 8. Pass an empty settings object `{}` and let the SDK pick defaults unless you have a specific reason to override. Explicit values like { sessionType: "VIDEO", layout: "TILE", ... } are technically valid per the type defs but have caused unexplained rendering issues in some version combinations — the sample app uses {}.
---
SessionId generation strategy
Pick ONE strategy and stick with it across your app:
| Strategy | Example sessionId | Use when |
|---|---|---|
| UUID per meeting | uuid-v4-string | One-off meetings, calendar invites |
| Stable per resource | team-${teamId} or appt-${appointmentId} | Recurring meetings, persistent rooms |
| Time-bucketed | office-hours-${dateYYYYMMDD} | Drop-in events |
Generate server-side so you can store metadata (start time, allowed participants, expiry) and authorize joins.
---
Token generation
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
async function getCallToken(sessionId: string): Promise<string> {
const result = await CometChatCalls.generateToken(sessionId);
if (!result?.token) throw new Error("Token generation failed");
return result.token;
}Each user calls this with the SAME sessionId but gets a token tied to their own identity. The token is short-lived (typically 5 min); don't cache aggressively.
---
Join session
This pattern mirrors the upstream sample exactly: /Users/swapnil/Downloads/calls-sdk/calls-sdk-javascript-5/sample-apps/cometchat-calls-sample-app-react/src/pages/join-session/JoinSession.tsx. Customer-validated against the v5 SDK as the known-good shape.
import { useEffect, useRef, useState } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
function CallRoom({ sessionId }: { sessionId: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const [inMeeting, setInMeeting] = useState(false);
// Auto-join once we have a sessionId. The state flip gates joinSession
// away from StrictMode's mount-phase double-effect (which raced
// leaveSession against an in-flight joinSession in prior versions).
useEffect(() => {
if (sessionId) setInMeeting(true);
}, [sessionId]);
// Reset state when the SDK reports the connection closed (peer left,
// network died, user clicked the in-iframe Leave button).
useEffect(() => {
const off = CometChatCalls.addEventListener("onConnectionClosed", () => {
setInMeeting(false);
});
return () => off();
}, []);
// The actual join happens in an effect that depends on `inMeeting` —
// the container ref is guaranteed populated because the <div> renders
// on every status, not behind a conditional.
useEffect(() => {
if (!inMeeting || !sessionId) return;
CometChatCalls.generateToken(sessionId).then(({ token }) => {
if (containerRef.current) {
// Empty settings = SDK defaults (matches sample app).
// The third arg is the container element; the SDK draws into it.
CometChatCalls.joinSession(token, {}, containerRef.current);
}
});
return () => {
try {
CometChatCalls.leaveSession();
} catch {
// ignore — not joined or already left
}
};
}, [inMeeting, sessionId]);
return (
<div
ref={containerRef}
style={{
position: "fixed",
top: 0,
left: 0,
width: "100vw",
height: "100vh",
}}
/>
);
}Why this shape (not the obvious alternatives):
- `position: fixed; 100vw × 100vh` instead of flex-sizing — gives the SDK a deterministic, viewport-anchored container the moment
joinSessionmeasures it. Customer integrations usingflex: 1+minHeight: 400reported "container visible but iframe renders at 0×0 → no video" in some browser × React-version combinations. Match the sample exactly. - Empty `{}` settings instead of explicit
{ sessionType, layout, ... }— both are type-valid, but the sample's{}is the empirically-validated combination. Override only when you have a specific reason. - `onConnectionClosed` instead of
onSessionLeft— fires on ANY session termination (peer left, network dropped, in-iframe Leave clicked), which is what most customers actually want for cleanup.onSessionLeftis narrower. - State-flag gating (
inMeeting) instead ofjoinedRef— the state flip putsjoinSessionin a state-triggered effect (which StrictMode doesn't double-run), avoiding the join↔leave race entirely.
---
Sharing the meeting link
Sample URL pattern: https://yourapp.com/meet/${sessionId}
function StartMeetingButton() {
async function start() {
// Server generates a unique sessionId, returns the meeting URL
const { sessionId, url } = await api.createMeeting();
// Copy to clipboard or open share sheet (see share-invite.md)
await navigator.clipboard.writeText(url);
// Navigate the host to the call room
window.location.href = `/meet/${sessionId}`;
}
return <button onClick={start}>Start meeting</button>;
}For end-to-end share UX (deep linking, login redirect, etc.), see share-invite.md.
---
Authorization (server-side)
Don't trust client-side joinSession. Your server should gate generateToken requests:
// Server-side route called by the client BEFORE generateToken
app.post("/api/meetings/:sessionId/authorize", async (req, res) => {
const { sessionId } = req.params;
const userId = req.session.userId;
const meeting = await db.getMeeting(sessionId);
if (!meeting) return res.status(404).json({ error: "Meeting not found" });
if (meeting.expiresAt < Date.now()) return res.status(410).json({ error: "Meeting expired" });
if (meeting.allowList && !meeting.allowList.includes(userId)) {
return res.status(403).json({ error: "Not authorized" });
}
res.json({ ok: true });
});Client-side flow: call /authorize → if 200, call generateToken → joinSession. Skipping authorization lets anyone with a sessionId join.
---
Anti-patterns
1. Sharing one user's token with another user. Tokens are user-bound; each participant calls generateToken with their own auth. 2. Caching tokens across sessions. Tokens are session-bound and short-lived. Generate fresh each join. 3. Calling `joinSession` before the container element renders. joinSession mounts UI into the element — if it's null, you get a runtime error. 4. `joinedRef` boolean guard for StrictMode. The cleanup function races against an in-flight joinSession and fires leaveSession before join completes — observed as "session connects then immediately leaves." Use the state-flag pattern shown above instead. 5. Flex-derived container sizing. flex: 1, height: 100%, or position: absolute; inset: 0 inside a nested flex parent can produce a measurable-looking container that renders the SDK iframe at 0×0 in some combinations. Use `position: fixed; width: 100vw; height: 100vh` — matches the sample app. 6. Explicit `{ sessionType, layout, ... }` settings. Type-valid but has caused unexplained black-tile-on-local-preview behaviour in customer integrations. The sample app uses {} and lets the SDK pick defaults. 7. No `error` check on the `joinSession` result. It returns {data, error} not a thrown error — Promise resolves on validation failure too. 8. Mixing Ringing + Call Session in one route. If you accept a ringing call AND mount a Call Session room with the same sessionId, both try to join — undefined behavior. 9. No expiry / authorization on sessionIds. Anyone with the URL can join forever. Always server-side gate. 10. Initializing Chat SDK for a session-only integration. Wastes time and adds two extra failure modes (Chat init failure, Chat login race) for zero benefit — session mode never touches the Chat SDK call entity. Drop CometChat.init / CometChat.login entirely for standalone session apps; keep them only for additive (chat + calls) integrations. 11. `CometChatCalls.getLoggedInUser().getUid()`. Runtime crash: existing.getUid is not a function. The Calls SDK's getLoggedInUser() returns a plain User_2 interface ({ uid, name, avatar, ... }), NOT the Chat SDK's User class. Access the uid as a property: existing.uid. Same applies to .name, .avatar — all plain properties. The .getUid() / .getName() / .getAvatar() getters belong to Chat SDK's User class, which session-only code does not import.
---
Verification checklist
- [ ] Server generates sessionIds; client doesn't mint them
- [ ]
/authorizeendpoint gatesgenerateToken - [ ] Tokens generated fresh per join (no caching)
- [ ] Container element rendered before
joinSessionruns - [ ] Container uses
position: fixed; width: 100vw; height: 100vh(sample-app pattern), NOT flex-derived sizing - [ ] StrictMode handled via state-flag gating (not
joinedRef) — see anti-pattern #4 - [ ] Settings passed as empty
{}, not explicit{ sessionType, layout, ... } - [ ]
result.errorchecked - [ ]
onConnectionClosedlistener handles session end (not justonSessionLeft) - [ ]
leaveSessionruns on unmount even if user closed tab - [ ] Standalone session-only: no
CometChat.init/CometChat.login— Calls SDK alone - [ ] Additive (chat + calls): dual-SDK contract preserved (Chat first, then Calls)
- [ ] Smoke: 2 tabs, both navigate to
/meet/${sessionId}→ both join → media flows → either leaves → other sees them gone
---
Pointers
cometchat-react-calls/SKILL.md— architecturecometchat-react-calls/references/ringing-integration.md— Mode 2 (ringing)cometchat-react-calls/references/share-invite.md— meeting-link sharingcometchat-calls/references/use-case-broadcast.md— broadcast pattern uses Call Sessioncometchat-calls/references/use-case-team.md— team huddles use Call Session- Canonical docs: https://www.cometchat.com/docs/calls/javascript/join-session
Custom call UI on web
When the kit's default <CometChatOngoingCall /> doesn't fit your app's design system, drop down to the Calls SDK directly. Two escalation paths:
1. Style the kit's component — pass style props / CSS variable overrides. Cheapest. Covers most cases. 2. Build your own surface on the SDK — use CometChatCalls.joinSession(token, settings, container) directly with your own DOM container. Maximum control. The kit doesn't render anything; you do. (startSession is a deprecated v4 shim — use joinSession.)
This reference covers path 2 — full custom UI on the SDK. Path 1 is in the kit's component documentation (see cometchat-customization).
---
Architecture
Your React component
├── Local user video (mic + camera preview) ← <video> element + getUserMedia
├── Remote participant video tile(s) ← <video> elements piped from Calls SDK
├── Custom control panel (mute, end, switch cam) ← buttons calling SDK methods
└── Layout (full-screen / picture-in-picture / grid) ← your CSSThe Calls SDK gives you:
- A
RTCMultiConnection-like internal connection it manages - Track-add events (when a participant's track arrives)
- Track-remove events
- Methods to mute/unmute, switch camera, end session
- A
htmlElementyou pass tostartSession— required — where the SDK draws the call surface. WithenableDefaultLayout(false), the SDK still uses this container for internal video elements; your custom UI overlays on top via absolute positioning, OR you keep the container hidden and use the listener events to drive your own<video>elements (advanced; see "Advanced — bypassing the SDK's container" below).
---
Hooking into track events
// CustomOngoingCallView.tsx
import { useEffect, useRef } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
interface Props {
sessionId: string;
authToken: string;
onCallEnded: () => void;
}
export function CustomOngoingCallView({ sessionId, authToken, onCallEnded }: Props) {
const localVideoRef = useRef<HTMLVideoElement>(null);
const remoteVideoRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const callListener = new CometChatCalls.OngoingCallListener({
onUserListUpdated: (userList: unknown) => {
// userList = current participants — re-render your custom roster
},
onCallEnded: () => {
cleanup();
onCallEnded();
},
onCallEndButtonPressed: () => {
// User clicked YOUR end button — we still have to call endSession
CometChatCalls.leaveSession();
},
onError: (error: unknown) => {
console.error("Call error:", error);
},
onAudioModesUpdated: (audioModes: unknown[]) => {
// available mic / speaker devices
},
onCallSwitchedToVideo: (call: unknown) => {
// remote upgraded the call from voice to video
},
onMediaDeviceListUpdated: (devices: unknown) => {
// user plugged in headphones, etc.
},
});
const settings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.setIsAudioOnly(false)
.enableDefaultLayout(false) // ← key: we render the UI ourselves
.setCallEventListener(callListener)
.build();
// v5 generateToken takes ONLY sessionId — authToken is internal after CometChatCalls.login().
CometChatCalls.generateToken(sessionId).then((tokenRes) => {
// htmlElement is REQUIRED — pass a container the SDK can draw into.
// With custom UI you typically render your own <video> elements; the
// container can be hidden but must still be a real DOM node.
const container = document.getElementById("calls-container")!;
// joinSession is the v5 canonical — startSession is a deprecated shim.
CometChatCalls.joinSession(tokenRes.token, settings, container);
});
return () => cleanup();
function cleanup() {
// leaveSession is v5 canonical — endSession() is deprecated (still works as a shim).
CometChatCalls.leaveSession();
if (localVideoRef.current) localVideoRef.current.srcObject = null;
if (remoteVideoRef.current) remoteVideoRef.current.srcObject = null;
}
}, [sessionId, authToken, onCallEnded]);
return (
<div className="ongoing-call">
<video ref={remoteVideoRef} autoPlay playsInline className="remote-tile" />
<video ref={localVideoRef} autoPlay playsInline muted className="local-tile" />
<ControlPanel
onMute={() => CometChatCalls.muteAudio(true)}
onUnmute={() => CometChatCalls.muteAudio(false)}
onCameraOff={() => CometChatCalls.pauseVideo(true)}
onCameraOn={() => CometChatCalls.pauseVideo(false)}
onSwitchCamera={() => CometChatCalls.switchCamera()}
onEnd={() => {
CometChatCalls.leaveSession();
onCallEnded();
}}
/>
</div>
);
}---
Custom control panel — the canonical mute/end/camera buttons
function ControlPanel(props: {
onMute: () => void;
onUnmute: () => void;
onCameraOff: () => void;
onCameraOn: () => void;
onSwitchCamera: () => void;
onEnd: () => void;
}) {
const [muted, setMuted] = useState(false);
const [cameraOff, setCameraOff] = useState(false);
return (
<div className="control-panel">
<button onClick={() => { muted ? props.onUnmute() : props.onMute(); setMuted(!muted); }}>
{muted ? "Unmute" : "Mute"}
</button>
<button onClick={() => { cameraOff ? props.onCameraOn() : props.onCameraOff(); setCameraOff(!cameraOff); }}>
{cameraOff ? "Camera on" : "Camera off"}
</button>
<button onClick={props.onSwitchCamera}>Switch camera</button>
<button onClick={props.onEnd} className="end-call">End</button>
</div>
);
}The SDK methods (muteAudio, pauseVideo, switchCamera) propagate to all participants via the SDK's signaling — you don't manage track state yourself.
---
Local preview (before the call connects)
For a "ringing" UI where the local user sees their own camera before the receiver picks up:
useEffect(() => {
let stream: MediaStream | null = null;
navigator.mediaDevices.getUserMedia({ video: true, audio: true }).then((s) => {
stream = s;
if (localVideoRef.current) localVideoRef.current.srcObject = s;
}).catch((err) => {
if (err.name === "NotAllowedError") setError("Camera/mic permission denied");
if (err.name === "NotFoundError") setError("No camera or mic on this device");
});
return () => {
stream?.getTracks().forEach((t) => t.stop()); // CRITICAL — release tracks (rule 1.3)
if (localVideoRef.current) localVideoRef.current.srcObject = null;
};
}, []);Once startSession runs, the SDK takes over the camera/mic — release your preview stream first or you'll have two consumers fighting over the device.
---
Layout customization
The Calls SDK is layout-agnostic when enableDefaultLayout(false). You compose remote tiles in any CSS layout:
- Spotlight — one large remote tile + small thumbnails for others. Track which user is speaking (
onActiveSpeakerUpdatedevent in some SDK builds) and swap the spotlight. - Grid — CSS grid with auto-fit columns, 1-N participant tiles equally sized.
- Picture-in-picture — small floating remote video that survives navigation. Mount it in a portal at the layout root (similar to
<CometChatIncomingCall />).
---
When to NOT go custom
- Default kit UI works for 80% of apps; custom is a real engineering investment.
- Recording / screen-share / participant-management features require deeper SDK plumbing —
references/recording-screen-share.mdcovers them but custom-UI authors must wire all of them themselves. - Kit components handle accessibility (keyboard nav, ARIA roles, focus traps) — custom must replicate.
- The kit is updated when CometChat ships breaking SDK changes; custom code is yours to migrate.
The dispatcher asks the user whether to go custom (defaults to "no — use kit") and only loads this reference when they say yes.
Device management on web
Camera / mic / speaker enumeration + switching mid-call. SDK exposes getAudioInputDevices() / getVideoInputDevices() / getAudioOutputDevices() plus setAudioInputDevice(deviceId) etc. Browser handles the underlying navigator.mediaDevices.enumerateDevices() plumbing.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/device-management Use it for: "I'm on AirPods, switch from laptop mic"; "this monitor's webcam is bad, use the external one"; pre-call device picker; in-call settings menu.
---
SDK API
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
// Enumerate
const mics = await CometChatCalls.getAudioInputDevices();
const speakers = await CometChatCalls.getAudioOutputDevices();
const cameras = await CometChatCalls.getVideoInputDevices();
// Currently selected
const currentMic = CometChatCalls.getCurrentAudioInputDevice();
// Switch (mid-call OK)
await CometChatCalls.setAudioInputDevice(deviceId);
await CometChatCalls.setAudioOutputDevice(deviceId);
await CometChatCalls.setVideoInputDevice(deviceId);
// Listen for device changes (user plugs in headphones, etc.)
CometChatCalls.addEventListener("onAudioModesUpdated", (devices) => {
// re-enumerate; show toast "AirPods connected"
});Each device looks like:
interface MediaDevice {
id: string; // browser-assigned ID
label: string; // "Built-in Microphone", "AirPods Pro"
// ... other fields per browser
}---
Pre-call device picker
import { useEffect, useState } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
interface MediaDevice { id: string; label: string; }
function DevicePicker({ onConfirm }: { onConfirm: () => void }) {
const [mics, setMics] = useState<MediaDevice[]>([]);
const [cameras, setCameras] = useState<MediaDevice[]>([]);
const [speakers, setSpeakers] = useState<MediaDevice[]>([]);
const [selectedMic, setSelectedMic] = useState<string>();
const [selectedCamera, setSelectedCamera] = useState<string>();
const [selectedSpeaker, setSelectedSpeaker] = useState<string>();
useEffect(() => {
async function load() {
// Devices have empty labels until permission is granted; trigger getUserMedia first
try {
await navigator.mediaDevices.getUserMedia({ audio: true, video: true });
} catch {
// permission denied — labels will be empty; offer settings link
}
const [m, c, s] = await Promise.all([
CometChatCalls.getAudioInputDevices(),
CometChatCalls.getVideoInputDevices(),
CometChatCalls.getAudioOutputDevices(),
]);
setMics(m); setCameras(c); setSpeakers(s);
setSelectedMic(CometChatCalls.getCurrentAudioInputDevice()?.id ?? m[0]?.id);
setSelectedCamera(CometChatCalls.getCurrentVideoInputDevice()?.id ?? c[0]?.id);
setSelectedSpeaker(CometChatCalls.getCurrentAudioOutputDevice()?.id ?? s[0]?.id);
}
load();
}, []);
async function confirm() {
if (selectedMic) await CometChatCalls.setAudioInputDevice(selectedMic);
if (selectedCamera) await CometChatCalls.setVideoInputDevice(selectedCamera);
if (selectedSpeaker) await CometChatCalls.setAudioOutputDevice(selectedSpeaker);
onConfirm();
}
return (
<form>
<label>
Microphone
<select value={selectedMic} onChange={(e) => setSelectedMic(e.target.value)}>
{mics.map(d => <option key={d.id} value={d.id}>{d.label || `Mic ${d.id.slice(0, 6)}`}</option>)}
</select>
</label>
<label>
Camera
<select value={selectedCamera} onChange={(e) => setSelectedCamera(e.target.value)}>
{cameras.map(d => <option key={d.id} value={d.id}>{d.label || `Camera ${d.id.slice(0, 6)}`}</option>)}
</select>
</label>
<label>
Speaker
<select value={selectedSpeaker} onChange={(e) => setSelectedSpeaker(e.target.value)}>
{speakers.map(d => <option key={d.id} value={d.id}>{d.label || `Speaker ${d.id.slice(0, 6)}`}</option>)}
</select>
</label>
<button type="button" onClick={confirm}>Join call</button>
</form>
);
}Empty labels gotcha: browsers return empty label strings until permission is granted. Trigger getUserMedia once before enumerating to populate labels.
---
Hot-swap during a call (settings menu)
function InCallDeviceSettings() {
const [showMenu, setShowMenu] = useState(false);
const [mics, setMics] = useState<MediaDevice[]>([]);
useEffect(() => {
if (!showMenu) return;
CometChatCalls.getAudioInputDevices().then(setMics);
const onModesUpdated = () => {
CometChatCalls.getAudioInputDevices().then(setMics);
};
CometChatCalls.addEventListener("onAudioModesUpdated", onModesUpdated);
return () => CometChatCalls.removeEventListener("onAudioModesUpdated", onModesUpdated);
}, [showMenu]);
async function selectMic(id: string) {
await CometChatCalls.setAudioInputDevice(id);
setShowMenu(false);
}
return (
<div>
<button onClick={() => setShowMenu(s => !s)}>Mic ▾</button>
{showMenu && (
<ul role="menu">
{mics.map(d => (
<li key={d.id}>
<button role="menuitem" onClick={() => selectMic(d.id)}>{d.label}</button>
</li>
))}
</ul>
)}
</div>
);
}onAudioModesUpdated fires when the user plugs in headphones, AirPods connect, etc. Re-enumerate on the event.
---
Bluetooth / AirPods routing
Browsers expose Bluetooth speakers in getAudioOutputDevices(). Selecting one calls HTMLMediaElement.setSinkId() under the hood — works in Chrome/Edge/Firefox, NOT in Safari (Safari uses system audio routing only).
// Detect Safari (limited device-output support)
const safari = /^((?!chrome|android).)*safari/i.test(navigator.userAgent);
if (safari) {
// Hide speaker selection; show "Use system audio" hint
}---
Anti-patterns
1. Calling `getAudioInputDevices` before requesting permission. Returns devices with empty label strings — UI shows "undefined" or empty entries. 2. Caching device list. Devices change (plug/unplug). Re-enumerate on every menu open AND on onAudioModesUpdated. 3. Hardcoding the first device as default. Browsers may sort differently (built-in vs USB). Read getCurrent*Device first; only fall back to first if no current selection. 4. Switching speakers in Safari. setAudioOutputDevice silently fails. Detect Safari and hide the speaker picker (or show a "iOS uses system routing" tooltip). 5. No listener cleanup on unmount. Event accumulates listeners on remount; spam every device change.
---
Verification checklist
- [ ] Permission requested before enumeration (empty-label fix)
- [ ]
getCurrent*Device()used as default in pickers - [ ]
onAudioModesUpdatedlistener for hot-swap detection - [ ] Listener cleanup on unmount
- [ ] Safari speaker picker hidden / fallback message
- [ ] Browser smoke: plug/unplug headphones during call, picker updates
- [ ] AirPods smoke: connect AirPods → "AirPods" appears in mic + speaker lists
- [ ] Multi-camera smoke: USB webcam + built-in, switch between
---
Pointers
cometchat-react-callsSKILL.md — the seven hard rulesreferences/custom-ui.md— full custom call UI integration- Canonical docs: https://www.cometchat.com/docs/calls/javascript/device-management
Group calls — broadcast meeting pattern (web)
Group calls in CometChat use a different signaling channel than 1:1 user calls. The Ringing flow (Chat SDK initiateCall → onIncomingCallReceived on peer) fires for 1:1 user calls only. For groups, the kit broadcasts a custom message of type meeting to the group; receivers see a "Join meeting" card in their message list (kit-based) OR need an explicit message listener (custom UI).
This is by-design kit behavior — not a bug. Documented across all platform kits.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/group-calls
---
Architecture
Caller (uid-A, member of group-X) CometChat Receivers (all other members of group-X)
│ │ │
│ <CometChatCallButtons group={x}> │ │
│ → CometChatUIKit.sendCustomMessage( │ │
│ CustomMessage(GUID, GROUP, │ │
│ "meeting", │ │
│ { callType, sessionId })) │ │
├─────────────────────────────────────────>│ │
│ │ onCustomMessageReceived │
│ ├─────────────────────────────>│ (each member's
│ │ │ MessageListener
│ │ │ fires if they're
│ │ │ in the group)
│ │ │
│ caller jumps straight to OngoingCall │ │
│ (joins session GUID directly) │ receiver taps "Join" │
│ │ → joinSession(GUID) │
│ CometChatCalls.joinSession(token, GUID) │ <─────────────────────────── │
├─────────────────────────────────────────>│ │
│ ───── WebRTC session active (sessionId = group GUID) ───── │Key contrast with 1:1 ringing:
| Channel | 1:1 user calls | Group calls |
|---|---|---|
| Signaling API | CometChat.initiateCall(call) | CometChatUIKit.sendCustomMessage(meetingMessage) |
| Receiver event | CallListener.onIncomingCallReceived | MessageListener.onCustomMessageReceived (category=CATEGORY_CUSTOM + type="meeting") |
| Session ID | server-generated unique per call | the group's GUID |
| Ring/decline semantics | yes — acceptCall / rejectCall | no — receivers just tap to join (or ignore) |
| Auto-cancel timeout | yes (45s default) | no — meeting card persists in chat history |
---
Hard rules
1. Group calls broadcast a custom message; they do NOT use the call listener. If your custom UI only registers addCallListener, group-call recipients will see NOTHING. Add a MessageListener that handles category === CATEGORY_CUSTOM && type === 'meeting'. 2. The session ID equals the group's GUID — not a generated unique ID like 1:1 calls. Anyone with the GUID can join the session at any time (it's persistent, not auto-cancelled). 3. Anyone can join, including after the meeting started. Late joiners are normal — the WebRTC session is open until all participants leave. UI must handle "joining an already-active meeting" as a valid state. 4. Joining = `CometChatCalls.joinSession(token, sessionSettings, container)` with sessionId set to the group GUID. Same generateToken(sessionId) step as 1:1 ringing — auth is internal after CometChatCalls.login(). 5. No `CometChat.endCall(sessionId)` on the chat side for groups — the meeting message is the persistent record, not a call entity. Each participant just calls CometChatCalls.leaveSession() when they hang up. 6. `onCallEndedMessageReceived` does NOT fire for group sessions. The session persists as long as anyone's in it. Use CometChatCalls.OngoingCallListener.onCallEnded to detect when YOUR client's session ends.
---
Caller side — kit-based
If you're using <CometChatMessageHeader> + <CometChatCallButtons>, the kit handles everything:
import { CometChatCallButtons } from "@cometchat/chat-uikit-react";
<CometChatCallButtons group={group} />Tapping voice/video automatically: 1. Sends a CustomMessage of type meeting to the group 2. Opens <CometChatOutgoingCall> UI (which transitions to <CometChatOngoingCall>) 3. Joins the WebRTC session with sessionId = group.getGuid()
No additional code on the caller side.
Caller side — custom UI
If you don't use kit components and want full control:
import { CometChat } from "@cometchat/chat-sdk-javascript";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
async function startGroupCall(guid: string, callType: "audio" | "video") {
const loggedInUser = await CometChat.getLoggedInUser();
if (!loggedInUser) throw new Error("Not logged in");
// 1. Broadcast the meeting message to the group
const sessionId = guid; // group GUID becomes the session ID
const customData = { callType, sessionId };
const meetingMessage = new CometChat.CustomMessage(
guid,
CometChat.RECEIVER_TYPE.GROUP,
"meeting",
customData,
);
meetingMessage.setCategory(CometChat.CATEGORY_CUSTOM);
meetingMessage.setMetadata({
incrementUnreadCount: true,
pushNotification: "meeting",
...customData,
});
await CometChat.sendCustomMessage(meetingMessage);
// 2. Generate a call token + join the session
const authToken = loggedInUser.getAuthToken();
const { token: callToken } = await CometChatCalls.generateToken(sessionId);
const callSettings = new CometChatCalls.CallSettingsBuilder()
.setIsAudioOnlyCall(callType === "audio")
.enableDefaultLayout(true)
.setCallListener(
new CometChatCalls.OngoingCallListener({
onCallEnded: () => { /* hangup UI */ },
onError: (err) => { /* show error */ },
}),
)
.build();
// 3. Mount the WebRTC view into your container element
await CometChatCalls.joinSession(callToken, callSettings, containerElement);
}The meeting message lands in the group's chat history immediately; other members get onCustomMessageReceived if they have a listener.
---
Receiver side — kit-based (auto-renders meeting card)
If your app uses <CometChatMessageList /> to render the group's messages, the kit auto-renders the meeting message as a "Join meeting" card with a tap target. No additional code needed:
<CometChatMessageList group={group} />When a user taps the meeting card, the kit: 1. Calls CometChatCalls.generateToken(sessionId, authToken) 2. Opens <CometChatOngoingCall> with sessionID={group.getGuid()} 3. Joins the WebRTC session
The receiver is now in the same WebRTC session as the caller (and any other members who joined).
Receiver side — custom UI (needs a message listener)
If you DON'T use <CometChatMessageList /> — e.g. you have a custom chat surface, OR you want incoming-meeting notifications globally — register a MessageListener and handle the meeting message yourself:
import { CometChat } from "@cometchat/chat-sdk-javascript";
const GROUP_MEETING_LISTENER_ID = "APP_GROUP_MEETING_LISTENER";
CometChat.addMessageListener(
GROUP_MEETING_LISTENER_ID,
new CometChat.MessageListener({
onCustomMessageReceived: (msg: CometChat.CustomMessage) => {
if (msg.getCategory() !== CometChat.CATEGORY_CUSTOM) return;
if (msg.getType() !== "meeting") return;
const customData = msg.getCustomData() as { callType?: "audio" | "video"; sessionId?: string };
const sessionId = customData.sessionId ?? msg.getReceiverId();
const callType = customData.callType ?? "video";
const fromUid = msg.getSender().getUid();
const groupGuid = msg.getReceiverId();
// Show YOUR group-call incoming UI here
// (e.g. toast notification with "Join" button, badge on the group, etc.)
showIncomingGroupCallUI({ sessionId, callType, fromUid, groupGuid });
},
}),
);
// On hangup or logout:
CometChat.removeMessageListener(GROUP_MEETING_LISTENER_ID);Tap-to-join from your custom UI:
async function joinGroupCall(sessionId: string, callType: "audio" | "video", container: HTMLElement) {
const loggedInUser = await CometChat.getLoggedInUser();
const authToken = loggedInUser!.getAuthToken();
const { token: callToken } = await CometChatCalls.generateToken(sessionId);
const callSettings = new CometChatCalls.CallSettingsBuilder()
.setIsAudioOnlyCall(callType === "audio")
.enableDefaultLayout(true)
.setCallListener(
new CometChatCalls.OngoingCallListener({
onCallEnded: () => { /* hangup UI */ },
}),
)
.build();
await CometChatCalls.joinSession(callToken, callSettings, container);
}No acceptCall step — receivers just join the session. The meeting message remains in the chat history regardless of who joins.
---
Edge cases
Late joining
A meeting can be active for an hour before a member opens the app. The custom message persists in chat history; tapping the card joins the live session.
UI implication: the meeting card should reflect live state. Listen for MessageListener.onCustomMessageReceived to add new cards; poll or use presence to know if the session is currently active. There's no built-in "meeting is live" signal — apps typically render the card with a "Join meeting" CTA regardless and let the WebRTC layer fail gracefully if everyone has left.
Cancelling / leaving
There's no "cancel the meeting" — the meeting message is permanent in chat history. Each participant leaves independently:
CometChatCalls.leaveSession(); // your local WebRTC session (v5 — endSession() is deprecated)
// No CometChat.endCall — meetings don't have a call entity.If you want to mark a meeting as "ended" UX-wise, send a follow-up message (e.g. another custom message of type meeting_ended) and have the receiver UI update its cards based on it. Not built into the SDK.
Push notifications
The meeting CustomMessage carries metadata.pushNotification = "meeting". The CometChat push system can route this to a "meeting started in your group" push. Configure on the dashboard side under Notifications → Push Notification.
Missed meetings
Since meetings don't ring, there's no "missed call" entity. If you want missed-meeting UI, derive it from message history: list meeting messages where the current user has NOT joined the session.
---
Anti-patterns
1. Registering only `addCallListener` and expecting group calls to ring. They won't. Group calls fire onCustomMessageReceived, not onIncomingCallReceived. Always add both listeners if you support both 1:1 and group calling. 2. Treating `sessionId` as ephemeral for groups. It's the group GUID — persistent. Don't generate a new sessionId per group call; the kit uses the GUID intentionally so all joiners hit the same WebRTC session. 3. Calling `CometChat.endCall(sessionId)` after a group hangup. Meetings have no call entity to end; the API returns an error. Use CometChatCalls.leaveSession() (local) only. 4. Using `acceptCall` / `rejectCall` on the meeting message. Those only work on 1:1 call entities. For meetings, you just join (no accept) or ignore (no reject). 5. Assuming all group members ring simultaneously. Only members with an active MessageListener get onCustomMessageReceived. Offline members see the meeting card when they next open the group. 6. Sending the meeting message without `metadata.pushNotification`. Offline members won't get a push. The kit handles this for you when you use <CometChatCallButtons>; if you're building custom, copy the metadata pattern in §"Caller side — custom UI".
---
Verification checklist
- [ ] If using kit components:
<CometChatCallButtons group={g}>renders + tap initiates a meeting message - [ ] If using custom caller UI:
CometChat.sendCustomMessageis called withtype: "meeting",category: CATEGORY_CUSTOM,customData: { callType, sessionId: groupGuid } - [ ] If using kit receiver:
<CometChatMessageList group={g}>renders the meeting card with a "Join" button - [ ] If using custom receiver:
addMessageListenerregistersonCustomMessageReceivedand filters by category + type - [ ] On hangup,
CometChatCalls.leaveSession()is called (NOTCometChat.endCall) - [ ] Late-joining works — open app while meeting is live; tap card; join session
- [ ] Push notifications fire for offline group members (server-side configured)
- [ ] Both listeners (
addCallListener+addMessageListener) wired if app supports both 1:1 and group calls
---
Pointers
ringing-integration.md— the 1:1 user-call flow (different channel — call listener, not message listener)call-session.md— pure session-mode (URL-based, no chat-side signaling at all)cometchat-react-calls/SKILL.mdrule 1.7 — IncomingCall mount (1:1 only; group calls don't use this)- Canonical docs: https://www.cometchat.com/docs/calls/javascript/group-calls
- Kit source (verified 2026-05-15):
node_modules/@cometchat/chat-uikit-react-native/src/calls/CometChatCallButtons/CometChatCallButtons.tsx:138-201
Idle timeout on web
Auto-ends calls where the local user is the only remaining participant. Two timers: a "you're alone, are you still there?" prompt fires after the first interval, then the session ends if the user doesn't respond before the second interval. SDK first-party — just two settings keys + one event.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/idle-timeout Use it for: any group call (preventing zombie sessions when everyone else hangs up), waiting rooms (auto-close empty rooms), classroom calls (instructor's screen doesn't stay live overnight).
---
SDK API
const settings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.setIdleTimeoutPeriodBeforePrompt(60_000) // 60s — first warning fires
.setIdleTimeoutPeriodAfterPrompt(120_000) // 120s — session ends if no response
.build();
CometChatCalls.addEventListener("onSessionTimedOut", () => {
// Navigate away, show "session ended" UI
});| Setting | Default | Min | Use case |
|---|---|---|---|
idleTimeoutPeriodBeforePrompt | 60_000 (60s) | 0 (immediate) | Time alone before the "still there?" prompt |
idleTimeoutPeriodAfterPrompt | 120_000 (120s) | 60_000 (60s) | Grace period after the prompt before forced disconnect |
The SDK shows the prompt overlay automatically (kit's default UI). For custom UI you handle the prompt yourself.
---
Recommended timeouts per app archetype
| App | Before-prompt | After-prompt | Why |
|---|---|---|---|
| Marketplace 1:1 | 30s | 60s | Buyers lose interest fast; reclaim resources |
| Telehealth (provider waiting) | 5min | 5min | Patients run late; don't punish them |
| Classroom / instructor | 10min | 10min | Instructor may step away; don't interrupt class |
| Internal team meeting | 60s | 120s | Default — meetings end abruptly anyway |
| Customer support | 30s | 60s | Agent moves to next ticket; reclaim |
---
Custom prompt UI (when default isn't enough)
The kit's default prompt is functional but plain. Custom UI uses enableDefaultLayout(false) + listens for the prompt internally — though the SDK doesn't expose a direct "prompt fired" event. Two patterns:
Pattern A — Track time-since-alone yourself
import { useEffect, useRef, useState } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
function useIdleTimeoutPrompt({
beforePromptMs = 60_000,
afterPromptMs = 120_000,
}: { beforePromptMs?: number; afterPromptMs?: number; }) {
const [showPrompt, setShowPrompt] = useState(false);
const aloneSinceRef = useRef<number | null>(null);
const promptTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
const listener = new CometChatCalls.OngoingCallListener({
onUserListUpdated: (users: { uid: string }[]) => {
const localUid = CometChat.getLoggedinUser()?.getUid();
const remoteCount = users.filter(u => u.uid !== localUid).length;
if (remoteCount === 0 && aloneSinceRef.current === null) {
// Just became alone
aloneSinceRef.current = Date.now();
promptTimerRef.current = setTimeout(() => {
setShowPrompt(true);
sessionTimerRef.current = setTimeout(() => {
CometChatCalls.leaveSession();
}, afterPromptMs);
}, beforePromptMs);
} else if (remoteCount > 0) {
// Someone joined — clear timers
aloneSinceRef.current = null;
if (promptTimerRef.current) clearTimeout(promptTimerRef.current);
if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current);
setShowPrompt(false);
}
},
});
// Attach listener... (see custom-ui.md for the full setup)
return () => {
if (promptTimerRef.current) clearTimeout(promptTimerRef.current);
if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current);
};
}, [beforePromptMs, afterPromptMs]);
function dismiss() {
setShowPrompt(false);
if (sessionTimerRef.current) clearTimeout(sessionTimerRef.current);
aloneSinceRef.current = null;
}
return { showPrompt, dismiss };
}Usage:
function CustomCallView() {
const { showPrompt, dismiss } = useIdleTimeoutPrompt({
beforePromptMs: 30_000,
afterPromptMs: 60_000,
});
return (
<>
{/* call UI */}
{showPrompt && (
<div role="alertdialog" aria-modal="true" aria-labelledby="idle-title">
<h2 id="idle-title">Still there?</h2>
<p>You're alone in this call. It'll end in 60 seconds.</p>
<button onClick={dismiss}>Stay</button>
<button onClick={() => CometChatCalls.leaveSession()}>End now</button>
</div>
)}
</>
);
}Pattern B — Long timeouts + custom prompt only
const settings = new CometChatCalls.CallSettingsBuilder()
.setIdleTimeoutPeriodBeforePrompt(86_400_000) // 24 hours — effectively disabled
.setIdleTimeoutPeriodAfterPrompt(86_400_000)
.build();Then implement your own timeout via Pattern A. Useful when you want UX-customized prompts instead of the SDK's default overlay.
---
Anti-patterns
1. `idleTimeoutPeriodAfterPrompt < 60_000` (60s). SDK rejects values below 60s. Setting it lower silently fails — the SDK uses 60s instead. 2. Disabling idle timeout entirely by setting both timers to Infinity. Sessions hang forever, server-side resources leak, billing dings you. Always set a reasonable upper bound. 3. Showing the prompt at the same time as call notifications/banners. Stacks on top of incoming-message notifications. Z-index conflicts. Render the idle prompt at the highest z-level for the call surface. 4. Auto-ending without a goodbye animation. Jarring. Fade-out the call surface over 500ms before disconnecting. 5. Prompt UI without `role="alertdialog"`. Screen readers don't pause to announce; user gets disconnected mid-listen. Use role="alertdialog" + aria-modal="true". 6. Restarting `aloneSinceRef.current` on EVERY participant change. Want to track "alone since" only when transitioning from N>0 to 0. Don't reset when participants join/leave while still N>0.
---
Verification checklist
- [ ]
setIdleTimeoutPeriodBeforePrompt+setIdleTimeoutPeriodAfterPromptset on CallSettings - [ ] After-prompt period ≥ 60_000 (60s)
- [ ]
onSessionTimedOutevent handler navigates away cleanly (not justconsole.log) - [ ] Custom prompt UI uses
role="alertdialog"+aria-modal="true" - [ ] Custom prompt has both "Stay" and "End now" buttons
- [ ] Timer cleanup in
useEffectreturn — no leaked setTimeout - [ ] Browser smoke: 2 tabs in call, close tab B, watch tab A's timer fire (set short timeouts for testing)
- [ ] After-prompt smoke: dismiss the prompt → timer clears → no auto-disconnect
---
Pointers
cometchat-react-callsSKILL.md — the seven hard rulesreferences/group-calls.md— group call architecture (idle-timeout matters most for groups)references/custom-ui.md— custom call UI integrationcometchat-a11y—role="alertdialog"patterns- Canonical docs: https://www.cometchat.com/docs/calls/javascript/idle-timeout
In-call chat on web
Text messaging during a video/voice call. SDK provides the chat button in the control panel + events; the actual chat UI is yours to build (the kit's existing <CometChatMessageList> + <CometChatMessageComposer> work great here).
Canonical docs: https://www.cometchat.com/docs/calls/javascript/in-call-chat Use it for: classroom side chat, team meeting links/notes, customer support secondary channel, AV-impaired participant text fallback.
---
SDK API
const settings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.hideChatButton(false) // show the kit's built-in chat button
.build();
CometChatCalls.addEventListener("onChatButtonClicked", () => {
// Open your chat panel
setChatOpen(true);
});
// Update unread badge
CometChatCalls.setChatButtonUnreadCount(5);
CometChatCalls.setChatButtonUnreadCount(0); // clearThe button + badge live on the SDK's control panel. Your job is to render the chat UI when the button is tapped.
---
Architecture: group-as-session
The recommended pattern: use a CometChat group keyed to the call session. Every participant joins the group when they join the call; messages flow through the group's normal CometChat Chat SDK channel. When the call ends, the group either persists (logged history) or is deleted (ephemeral).
import { CometChat } from "@cometchat/chat-sdk-javascript";
async function ensureCallGroup(sessionId: string): Promise<CometChat.Group> {
// Try to get existing
try {
return await CometChat.getGroup(sessionId);
} catch {
// Create
const group = new CometChat.Group(
sessionId, // GUID == session ID
`Call ${sessionId}`, // display name
CometChat.GROUP_TYPE.PUBLIC, // anyone in the call can join
);
return await CometChat.createGroup(group);
}
}
async function joinCallGroup(sessionId: string): Promise<void> {
await CometChat.joinGroup(sessionId, CometChat.GROUP_TYPE.PUBLIC, "");
}Wire this into your call-start flow: after startSession succeeds, joinCallGroup.
---
Chat panel UI
import { useState, useEffect } from "react";
import { CometChat } from "@cometchat/chat-sdk-javascript";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
import {
CometChatMessageList,
CometChatMessageComposer,
} from "@cometchat/chat-uikit-react";
interface InCallChatPanelProps {
sessionId: string;
}
function InCallChatPanel({ sessionId }: InCallChatPanelProps) {
const [open, setOpen] = useState(false);
const [unread, setUnread] = useState(0);
const [group, setGroup] = useState<CometChat.Group>();
// Listen for the SDK's button click
useEffect(() => {
const handler = () => setOpen(true);
CometChatCalls.addEventListener("onChatButtonClicked", handler);
return () => CometChatCalls.removeEventListener("onChatButtonClicked", handler);
}, []);
// Resolve the group object for the message list
useEffect(() => {
CometChat.getGroup(sessionId).then(setGroup).catch(() => {});
}, [sessionId]);
// Track unread when panel is closed
useEffect(() => {
const listenerId = `in-call-chat-${sessionId}`;
const messageListener = new CometChat.MessageListener({
onTextMessageReceived: (msg: CometChat.TextMessage) => {
if (
msg.getReceiverType() === CometChat.RECEIVER_TYPE.GROUP &&
msg.getReceiverId() === sessionId &&
!open
) {
setUnread((u) => {
const next = u + 1;
CometChatCalls.setChatButtonUnreadCount(next);
return next;
});
}
},
});
CometChat.addMessageListener(listenerId, messageListener);
return () => CometChat.removeMessageListener(listenerId);
}, [sessionId, open]);
// Clear badge on open
useEffect(() => {
if (open) {
setUnread(0);
CometChatCalls.setChatButtonUnreadCount(0);
}
}, [open]);
if (!open || !group) return null;
return (
<div
role="dialog"
aria-label="In-call chat"
style={{
position: "fixed",
right: 0,
top: 0,
bottom: 0,
width: 360,
background: "var(--cometchat-background-color-01)",
borderLeft: "1px solid var(--cometchat-border-color-light)",
display: "flex",
flexDirection: "column",
zIndex: 200,
}}
>
<div style={{ padding: "12px 16px", borderBottom: "1px solid var(--cometchat-border-color-light)", display: "flex", justifyContent: "space-between", alignItems: "center" }}>
<strong>Chat</strong>
<button onClick={() => setOpen(false)} aria-label="Close chat">×</button>
</div>
{/* The flex-1 + minHeight:0 dance applies here too — long chat → composer would push down */}
<div style={{ flex: "1 1 0", minHeight: 0, overflow: "hidden" }}>
<CometChatMessageList group={group} hideReplyInThreadOption />
</div>
<div style={{ flex: "0 0 auto" }}>
<CometChatMessageComposer group={group} />
</div>
</div>
);
}The flex-1 + minHeight:0 + wrap-each-component pattern from cometchat-react-patterns rule 6a applies inside the chat panel too.
---
Compact UX patterns
For 1:1 calls or small groups, an in-call chat panel is overkill. Two lighter patterns:
A — Floating message strip (top of call)
Show only the latest message inline; tap to open full chat. Minimal UI for "just the gist."
B — System-toast on each message
onTextMessageReceived → toast at top of call. Auto-dismiss after 4s. No persistent panel.
The full panel pattern above is for 5+-person calls or when chat is a load-bearing part of the experience (e.g. classroom Q&A).
---
Anti-patterns
1. Polling for unread count. SDK's setChatButtonUnreadCount is push-based; respond to message events. 2. Chat panel grows past viewport on long sessions. flex-shrink trap (see cometchat-react-patterns rule 6a). 3. Forgetting to clear the unread badge on panel open. User opens, sees "12 new" badge that doesn't update — looks broken. 4. Persisting the call group forever. For ephemeral calls (consultations, support), delete the group on call end. Hosts of recurring meetings keep groups. 5. Sending messages without auth. Make sure CometChat.login resolved before joining the group. Race conditions on early call accept. 6. Reusing one chat-panel instance across multiple calls. Group reference goes stale. Re-mount per call.
---
Verification checklist
- [ ]
hideChatButton: falsein CallSettings - [ ]
onChatButtonClickedlistener opens the panel - [ ] CometChat group created/resolved with sessionId as GUID
- [ ] Local user joins the group on call start
- [ ] Unread badge updates from
onTextMessageReceivedonly when panel closed - [ ] Badge cleared on panel open
- [ ] Chat panel uses flex-1 + minHeight:0 + wrap-each-component pattern
- [ ]
role="dialog"+aria-labelon the panel container - [ ] Cleanup: remove message + chat-button listeners on call end
- [ ] Browser smoke: 2 tabs in call, send message from A → unread badge in B → tap → message visible
---
Pointers
cometchat-react-callsSKILL.mdcometchat-react-patterns— minHeight:0 rule 6a (applies to in-call chat panel)references/group-calls.md— group call architecturecometchat-android-v5-calls/references/in-call-chat.md— Android V5 sibling reference- Canonical docs: https://www.cometchat.com/docs/calls/javascript/in-call-chat
Calls SDK v4 → v5 migration (web)
CometChat Calls SDK v5 is a drop-in replacement for v4 — bump the package, your existing code keeps working through deprecation shims. Migrate to v5 APIs to get granular event listeners, simpler init, and strongly-typed enums.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/migration-guide-v5
---
Step 1 — Bump the package
npm install @cometchat/calls-sdk-javascript@5If you're using CometChat UI Kits, this is enough — the kit's calls integration uses the v4 deprecated layer. You can ship to production at this step and migrate v5 APIs incrementally.
---
Step 2 — Migrate init (optional but cleaner)
- const callAppSettings = new CometChatCalls.CallAppSettingsBuilder()
- .setAppId("APP_ID")
- .setRegion("REGION")
- .build();
- await CometChatCalls.init(callAppSettings);
+ await CometChatCalls.init({ appId: "APP_ID", region: "REGION" });---
Step 3 — Add login() after Chat SDK login
v5 introduces a dedicated Calls SDK auth step. After the user logs into the Chat SDK, call:
const authToken = (await CometChat.getLoggedinUser())!.getAuthToken();
await CometChatCalls.login(authToken);After this, generateToken() and joinSession() no longer need an authToken parameter.
---
Step 4 — Migrate session settings to plain object
- const callSettings = new CometChatCalls.CallSettingsBuilder()
- .setIsAudioOnlyCall(true)
- .showRecordingButton(true)
- .startWithAudioMuted(false)
- .build();
+ const sessionSettings = {
+ sessionType: "VOICE", // was setIsAudioOnlyCall(true)
+ hideRecordingButton: false, // was showRecordingButton(true) — INVERTED
+ startAudioMuted: false,
+ layout: "TILE",
+ };Watch out: the boolean logic is inverted for many settings (v4 show* → v5 hide*). Search-and-replace doesn't work — review each manually.
---
Step 5 — Migrate events to granular listeners
- const callSettings = new CometChatCalls.CallSettingsBuilder()
- .setCallListener(new CometChatCalls.OngoingCallListener({
- onCallEnded: () => { /* ... */ },
- onUserJoined: (user) => { /* ... */ },
- onUserLeft: (user) => { /* ... */ },
- onError: (error) => { /* ... */ },
- }))
- .build();
+ const unsub1 = CometChatCalls.addEventListener("onSessionLeft", () => { /* ... */ });
+ const unsub2 = CometChatCalls.addEventListener("onParticipantJoined", (p) => { /* ... */ });
+ const unsub3 = CometChatCalls.addEventListener("onParticipantLeft", (p) => { /* ... */ });
+ // Errors now come back via Promise rejection from joinSession() etc.
+
+ // Cleanup
+ return () => { unsub1(); unsub2(); unsub3(); };Event-name mapping (most common):
| v4 | v5 |
|---|---|
onCallEnded | onSessionLeft |
onCallEndButtonPressed | onLeaveSessionButtonClicked |
onUserJoined(user) | onParticipantJoined(participant) |
onUserLeft(user) | onParticipantLeft(participant) |
onUserListUpdated(list) | onParticipantListChanged(list) |
onUserMuted(info) | onParticipantAudioMuted(participant) |
onRecordingToggled(info) | onRecordingStarted / onRecordingStopped |
---
Step 6 — Migrate session control method names
- CometChatCalls.endSession()
+ CometChatCalls.leaveSession()
- CometChatCalls.muteAudio(true)
+ CometChatCalls.muteAudio()
- CometChatCalls.muteAudio(false)
+ CometChatCalls.unmuteAudio()
- CometChatCalls.pauseVideo(true)
+ CometChatCalls.pauseVideo()
- CometChatCalls.pauseVideo(false)
+ CometChatCalls.resumeVideo()
- CometChatCalls.setMode(mode)
+ CometChatCalls.setLayout(layout)
- CometChatCalls.startScreenShare()
+ CometChatCalls.startScreenSharing()
- CometChatCalls.enterPIPMode()
+ CometChatCalls.enablePictureInPictureLayout()---
Step 7 — startSession → joinSession
- CometChatCalls.generateToken(sessionId, authToken).then((token) => {
- CometChatCalls.startSession(token, callSettings, container);
- });
+ CometChatCalls.generateToken(sessionId).then((token) => {
+ CometChatCalls.joinSession(token, sessionSettings, container);
+ });
+
+ // Or — pass the sessionId directly (no manual token mint):
+ CometChatCalls.joinSession(sessionId, sessionSettings, container);---
Removed methods (no v5 replacement)
CometChatCalls.switchToVideoCall()— start a fresh session withsessionType: "VIDEO"insteadCometChatCalls.getCallDetails()— track session state via events
---
Verification checklist
- [ ]
package.jsonlists@cometchat/calls-sdk-javascript@^5 - [ ]
await CometChatCalls.login(authToken)called afterCometChat.login - [ ]
OngoingCallListenerremoved; granularaddEventListenercalls in place with cleanup - [ ]
startSessionreplaced withjoinSession - [ ]
endSessionreplaced withleaveSession - [ ] Inverted booleans audited (show → hide with
!) - [ ] No use of removed methods (
switchToVideoCall,getCallDetails) - [ ] Existing call flows tested end-to-end (incoming, accept, mute, screen-share, end)
---
Pointers
- Canonical migration guide: https://www.cometchat.com/docs/calls/javascript/migration-guide-v5
cometchat-react-calls/SKILL.md— current architecture (v5)cometchat-react-calls/references/call-layouts.md— layout enum migrationcometchat-react-calls/references/recording.md— recording events migration
Picture-in-Picture on web
Web has two PiP APIs, used for different things:
1. Video PiP (HTMLVideoElement.requestPictureInPicture()) — lets a single <video> element float in a system-managed window above all browser tabs. Standard since Chrome 70 / Safari 13 / Firefox 71. Fine for one remote participant.
2. Document PiP (window.documentPictureInPicture.requestWindow()) — lets you put arbitrary HTML (custom call UI with controls, multi-tile grid, roster) in a floating window. Chrome 116+ only. Falls back gracefully where unsupported.
This reference covers both, plus when to pick which.
---
When to use Video PiP vs Document PiP
| Scenario | Pick |
|---|---|
| 1:1 call, just want the remote face floating while user works | Video PiP |
| Multi-party call, want the active speaker + a small roster floating | Document PiP if Chrome 116+, else fall back to Video PiP |
| Want call controls (mute/end) visible in the PiP window | Document PiP only — Video PiP doesn't allow custom controls |
| Cross-browser support including Safari + Firefox | Video PiP (with fallback when neither works) |
The skill defaults to Video PiP for cross-browser compatibility; Document PiP is opt-in for Chromium-only apps.
---
Video PiP — the simple path
// CustomOngoingCallView.tsx — extends the version in references/custom-ui.md
const remoteVideoRef = useRef<HTMLVideoElement>(null);
const [pipActive, setPipActive] = useState(false);
async function enterPiP() {
const video = remoteVideoRef.current;
if (!video) return;
if (!document.pictureInPictureEnabled) {
setError("Picture-in-Picture isn't supported in this browser");
return;
}
try {
await video.requestPictureInPicture();
setPipActive(true);
} catch (err) {
// user denied, video not yet playing, etc.
console.warn("PiP request failed:", err);
}
}
useEffect(() => {
const video = remoteVideoRef.current;
if (!video) return;
const onEnter = () => setPipActive(true);
const onLeave = () => setPipActive(false);
video.addEventListener("enterpictureinpicture", onEnter);
video.addEventListener("leavepictureinpicture", onLeave);
return () => {
video.removeEventListener("enterpictureinpicture", onEnter);
video.removeEventListener("leavepictureinpicture", onLeave);
};
}, []);The <video> element keeps playing — PiP doesn't pause or remount. CSS doesn't apply (the OS owns the floating window). Hide the in-page video when PiP is active to avoid the "two videos playing" UX:
<video
ref={remoteVideoRef}
autoPlay
playsInline
style={{ display: pipActive ? "none" : "block" }}
/>---
Document PiP — the rich path (Chrome 116+)
const [pipWindow, setPipWindow] = useState<Window | null>(null);
async function enterDocumentPiP() {
// Feature detect
if (!("documentPictureInPicture" in window)) {
return enterPiP(); // fall through to video PiP
}
const pipWin = await (window as unknown as {
documentPictureInPicture: { requestWindow: (opts: { width: number; height: number }) => Promise<Window> };
}).documentPictureInPicture.requestWindow({
width: 360,
height: 480,
});
// Copy the call container into the PiP window
const container = document.getElementById("ongoing-call-root");
if (container) {
pipWin.document.body.appendChild(container);
}
// PiP window has its own document — copy stylesheets so kit styling works
for (const styleSheet of Array.from(document.styleSheets)) {
try {
const cssRules = Array.from(styleSheet.cssRules ?? []).map((r) => r.cssText).join("\n");
const style = pipWin.document.createElement("style");
style.textContent = cssRules;
pipWin.document.head.appendChild(style);
} catch {
// cross-origin stylesheets throw — copy <link> href instead
if (styleSheet.href) {
const link = pipWin.document.createElement("link");
link.rel = "stylesheet";
link.href = styleSheet.href;
pipWin.document.head.appendChild(link);
}
}
}
// When the user closes the PiP window (system X button), restore the container
pipWin.addEventListener("pagehide", () => {
const restored = pipWin.document.getElementById("ongoing-call-root");
if (restored && document.getElementById("call-host")) {
document.getElementById("call-host")!.appendChild(restored);
}
setPipWindow(null);
});
setPipWindow(pipWin);
}The container keeps its event handlers and React fiber attached — clicking "End" inside the PiP window still calls your React handlers. This is the magic of Document PiP that single-video PiP doesn't give you.
Caveat: stylesheets are copied at PiP-open time. If you change the theme mid-PiP (light/dark toggle), styles in the PiP window go stale. Add a MutationObserver or just don't allow theme switching while PiP is active.
---
Browser support matrix
| Browser | Video PiP | Document PiP |
|---|---|---|
| Chrome 70+ desktop | ✓ | Chrome 116+ |
| Edge 79+ desktop | ✓ | Edge 116+ |
| Safari 13+ desktop | ✓ | ✗ (no plans yet) |
| Firefox 71+ desktop | ✓ (custom toggle UI, not standard API) | ✗ |
| Chrome mobile (Android) | ✓ system-PiP equivalent | ✗ |
| Safari iOS | iPad: ✓; iPhone: limited | ✗ |
Feature-detect both. Don't render the "Enter PiP" button when neither is supported.
const canVideoPiP = typeof document !== "undefined" && document.pictureInPictureEnabled;
const canDocPiP = typeof window !== "undefined" && "documentPictureInPicture" in window;
const showPipButton = canVideoPiP || canDocPiP;---
Auto-enter PiP on tab switch
A "tab visibility" pattern many call UX teams want — auto-enter PiP when the user switches away from the call tab:
useEffect(() => {
const onVisibilityChange = () => {
if (document.visibilityState === "hidden" && remoteVideoRef.current) {
remoteVideoRef.current.requestPictureInPicture().catch(() => {});
}
};
document.addEventListener("visibilitychange", onVisibilityChange);
return () => document.removeEventListener("visibilitychange", onVisibilityChange);
}, []);Browsers reject auto-PiP requests not tied to user gestures in some contexts (Safari is strictest). Use await navigator.mediaSession.setActionHandler("enterpictureinpicture", ...) for a cleaner API where supported.
---
Auto-leave PiP on hangup
When the call ends, exit PiP cleanly:
function endCall() {
if (document.pictureInPictureElement) {
document.exitPictureInPicture();
}
if (pipWindow) {
pipWindow.close();
setPipWindow(null);
}
CometChatCalls.leaveSession();
// ...rest of cleanup
}Without this, the PiP window stays floating after the call ends, showing a frozen frame.
---
PiP + custom UI integration
If you're using enableDefaultLayout(true) (kit-rendered call UI), PiP works on the kit's internal <video> element. Reach into it via:
const callContainer = document.getElementById("calls-container");
const video = callContainer?.querySelector("video"); // kit renders one or more
if (video instanceof HTMLVideoElement) {
await video.requestPictureInPicture();
}Brittle — kit DOM structure can change between versions. Custom UI (Document PiP path above) is more stable.
---
Anti-patterns
1. Calling `requestPictureInPicture()` from `useEffect` on mount. Browsers reject — must be in response to user gesture. Wire to a button. 2. Forgetting to hide the in-page `<video>` while PiP is active. Two videos play, audio doubles, layout breaks. 3. Document PiP without copying stylesheets. PiP window renders unstyled; user sees raw HTML. 4. Not exiting PiP on hangup. Frozen frame floats after call ends. 5. Document PiP detection via `'documentPictureInPicture' in document`. It's on window, not document. Common typo. 6. Auto-PiP on every visibility change, including page reload. User reloads → unintended PiP. Gate on call active + user-initiated focus loss.
---
Verification checklist
- [ ] PiP button only renders if
document.pictureInPictureEnabledOR'documentPictureInPicture' in window - [ ] PiP request triggered from a click handler, not
useEffect - [ ] In-page video hidden while PiP active (or repositioned)
- [ ]
enterpictureinpicture/leavepictureinpicturelisteners update local state - [ ] Hangup path calls
document.exitPictureInPicture()if active - [ ] Document PiP path copies stylesheets to the PiP window's document
- [ ] Document PiP path restores the container to the main window on
pagehide - [ ] Real-browser smoke: Chrome desktop (both APIs) + Safari desktop (Video PiP) + Firefox (Video PiP)
---
Pointers
- Custom UI integration:
references/custom-ui.md - Kit-default layout: kit handles internal video PiP via the kit's own controls
- Document PiP spec: https://wicg.github.io/document-picture-in-picture/
cometchat-react-callsSKILL.md — base hard rules
Raise hand on web
Lets participants signal they want to speak without interrupting the current speaker. The SDK ships first-party support — four method calls, two events, one settings flag. No custom signaling needed.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/raise-hand Use it for: classrooms, large group calls, town halls, any call with > ~5 participants where verbal turn-taking gets messy.
---
SDK API (web Calls SDK)
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
// Local user raises hand
CometChatCalls.raiseHand();
// Local user lowers hand
CometChatCalls.lowerHand();
// Subscribe to other participants' hand state
CometChatCalls.addEventListener("onParticipantHandRaised", (participant) => {
// participant.uid, participant.name available
});
CometChatCalls.addEventListener("onParticipantHandLowered", (participant) => {
// ...
});The SDK ships a built-in raise-hand button in the default control panel. Hide it via call settings if you're rolling custom UI:
const callSettings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.hideRaiseHandButton(true) // suppress the SDK's button — your UI takes over
.build();---
When to use built-in vs custom
| Scenario | Use |
|---|---|
| Default kit UI is fine; just want raise-hand | Built-in (don't pass hideRaiseHandButton) |
| Custom call UI (your own control panel) | Custom — call raiseHand() / lowerHand() from your buttons |
| Need different host vs participant UI | Custom — query group scope, render different controls |
| Need raise-hand list (host sees who's raised) | Custom — maintain local Map<uid, raisedAt> via the listeners |
---
Custom raise-hand UX — three pieces
1. Local participant button (toggle)
import { useState } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
function RaiseHandButton() {
const [raised, setRaised] = useState(false);
function toggle() {
if (raised) {
CometChatCalls.lowerHand();
setRaised(false);
} else {
CometChatCalls.raiseHand();
setRaised(true);
}
}
return (
<button onClick={toggle} aria-pressed={raised}>
{raised ? "✋ Lower" : "✋ Raise hand"}
</button>
);
}Visual hint: render the icon with aria-pressed={raised} so screen readers announce the toggle state. (See cometchat-a11y for the broader rule.)
2. Raised-hands roster (host view)
import { useEffect, useState } from "react";
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
interface RaisedParticipant { uid: string; name: string; raisedAt: number; }
function RaisedHandsList() {
const [raised, setRaised] = useState<Map<string, RaisedParticipant>>(new Map());
useEffect(() => {
const onRaised = (p: { uid: string; name: string }) => {
setRaised(prev => {
const next = new Map(prev);
next.set(p.uid, { ...p, raisedAt: Date.now() });
return next;
});
};
const onLowered = (p: { uid: string }) => {
setRaised(prev => {
const next = new Map(prev);
next.delete(p.uid);
return next;
});
};
CometChatCalls.addEventListener("onParticipantHandRaised", onRaised);
CometChatCalls.addEventListener("onParticipantHandLowered", onLowered);
return () => {
CometChatCalls.removeEventListener("onParticipantHandRaised", onRaised);
CometChatCalls.removeEventListener("onParticipantHandLowered", onLowered);
};
}, []);
// Sort oldest-first — fairness queue
const sorted = Array.from(raised.values()).sort((a, b) => a.raisedAt - b.raisedAt);
if (sorted.length === 0) return null;
return (
<ul aria-label="Raised hands queue">
{sorted.map(p => (
<li key={p.uid}>
✋ {p.name} <span style={{ color: "#888" }}>{secondsAgo(p.raisedAt)}</span>
</li>
))}
</ul>
);
}
function secondsAgo(t: number) { return `${Math.round((Date.now() - t) / 1000)}s ago`; }Sort by raisedAt ascending = first-raised-first-called, which feels fair to participants. Don't sort alphabetically.
3. Toast notification for the host
useEffect(() => {
const onRaised = (p: { name: string }) => {
toast.info(`${p.name} raised their hand`, { duration: 4000 });
};
CometChatCalls.addEventListener("onParticipantHandRaised", onRaised);
return () => CometChatCalls.removeEventListener("onParticipantHandRaised", onRaised);
}, []);Use aria-live="polite" on the toast region so screen readers announce — same a11y pattern as new-message announcements (cf. cometchat-a11y).
---
Lower-by-host pattern
The SDK exposes lowerHand() only for the local user. To let a host lower someone else's hand, you need a moderator action via the participant-management API:
// Host action — requires moderator/admin scope on the group
async function lowerParticipantHand(uid: string) {
// SDK doesn't expose remoteLowerHand directly. Two options:
// A) Send a custom message to the participant; their client lowers itself
// B) Use the moderator mute/kick API as the boundary
// Option A: lightweight, requires the participant's client to listen
await CometChat.sendCustomMessage(new CometChat.CustomMessage(
uid, CometChat.RECEIVER_TYPE.USER, "lower_hand", {}
));
}On the receiving side:
CometChat.addMessageListener("raise-hand-control", new CometChat.MessageListener({
onCustomMessageReceived: (msg) => {
if (msg.getType() === "lower_hand") {
CometChatCalls.lowerHand();
}
},
}));This is application-level signaling, not SDK-built-in. Document the contract in your team's call protocols.
---
Hide button on rendered surfaces
If using the kit's <CometChatOngoingCall /> and want raise-hand off entirely (e.g. 1:1 calls don't need it):
import { CometChatCalls } from "@cometchat/calls-sdk-javascript";
const settings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.hideRaiseHandButton(true)
.build();For 1:1 calls, default to hidden. For group calls > 5 participants, default to shown.
---
Anti-patterns
1. Polling for raised-hand state. The SDK fires events on change — listen, don't poll. Polling burns CPU. 2. Sorting raised-hands alphabetically. First-raised-first-called is the fair queue. Sort by raisedAt ascending. 3. Auto-lowering hands after a timer. Some people raise their hand and wait. Letting the SDK manage state means the participant lowers when called or via their own button. 4. Showing raise-hand button in 1:1 calls. Visually noisy and pointless. Gate on participantCount > 2. 5. Skipping the listener cleanup in `useEffect`'s return. Stacked listeners fire multiple times when the component re-mounts — duplicate toasts, duplicate roster entries. 6. Treating "hand raised" as a permission grant. Raise-hand is a request, not a mute override. The host still has to unmute the participant separately.
---
Verification checklist
- [ ]
raiseHand()/lowerHand()calls in your code (not just relying on the SDK button) - [ ] Both
onParticipantHandRaisedandonParticipantHandLoweredlisteners registered - [ ] Listeners cleaned up in component unmount (return from
useEffect) - [ ] Roster sorted by
raisedAtascending - [ ]
hideRaiseHandButton: truein call settings IF custom UI is used (otherwise duplicates) - [ ] Toast / badge UI uses
aria-live="polite"(a11y) - [ ] Browser smoke: 3 tabs, hand-raise from 2 of them, host's roster shows both in raise-order
- [ ] Lower-hand smoke: tab A raises, lowers, host's roster updates without page refresh
---
Pointers
cometchat-react-callsSKILL.md — the seven hard rules (still apply for raise-hand UI)references/group-calls.md— group call architecture (raise-hand is a group-call feature)references/custom-ui.md— custom call UI patternscometchat-a11y— toast announcements for raised-hand events- Canonical docs: https://www.cometchat.com/docs/calls/javascript/raise-hand
- For deeper SDK reference (other event types, presenter-mode interplay): query the docs MCP at
https://www.cometchat.com/docs/mcp
Recording + screen sharing on web
Both features ship with the Calls SDK; both have web-specific gotchas the kit's defaults don't handle.
---
Recording
Server-side: enable in the dashboard first
Recording is a paid feature gated by your CometChat plan. Enable it in Dashboard → Chat & Messaging → Calls → Recording. Without that, the client-side flag below is a no-op.
Client-side: opt-in per session
const settings = new CometChatCalls.CallSettingsBuilder()
.setSessionID(sessionId)
.setIsAudioOnly(false)
.enableRecording(true) // ← server starts recording when session begins
.setShowRecordingButton(true) // ← user-toggleable mid-call
.build();Two flags, two behaviors:
enableRecording(true)— recording starts the moment the session begins. Server-side flag.setShowRecordingButton(true)— exposes a "Record" toggle in the default control panel. User decides when to start/stop. Custom-UI code must wire its own button.
Compliance note: in some jurisdictions you must notify all participants before recording starts. The default kit UI shows a small "Recording" indicator; if you're using custom UI, you must render this yourself. The skill's verification checklist flags this.
Recording lifecycle events
const listener = new CometChatCalls.OngoingCallListener({
onRecordingStarted: (rec: unknown) => {
// server confirmed recording is active
},
onRecordingStopped: (rec: unknown) => {
// server stopped — file will appear in dashboard within ~30 seconds
},
onRecordingFailed: (error: unknown) => {
// surface to UI — usually plan limits or storage quota
},
});Where the recordings go
CometChat hosts the file. It appears in Dashboard → Calls → Recordings with a download link. The skill points users at the dashboard path; there is no client-side download API.
---
Screen sharing
Two roles: presenter + viewer
- Presenter (the user sharing their screen) — calls
CometChatCalls.startScreenShare()and receives aMediaStreamfromgetDisplayMedia - Viewer (everyone else) — sees the presenter's screen as another video tile, no special API call needed
Browser support: Chrome/Edge (full), Firefox (full), Safari 13+ (full). On mobile browsers, getDisplayMedia is supported on iOS 16+ Safari and recent Android Chrome.
Presenter — start sharing
async function startScreenShare() {
try {
await CometChatCalls.startScreenShare();
// SDK handled getDisplayMedia + signaling; UI updates via onScreenShareStarted
} catch (err: unknown) {
if ((err as Error).name === "NotAllowedError") {
// user clicked "Cancel" on the picker — no error UI needed
return;
}
setError("Couldn't start screen share");
}
}Stop sharing:
CometChatCalls.endScreenShare();The browser also fires its own "Stop sharing" button (the system overlay Chrome shows during a screen-share). The SDK listens for this too; onScreenShareEnded fires either way.
Viewer — listen for screen-share events
const listener = new CometChatCalls.OngoingCallListener({
onScreenShareStarted: (presenterUid: string, stream: MediaStream) => {
// attach the stream to a <video> element
if (screenShareVideoRef.current) {
screenShareVideoRef.current.srcObject = stream;
}
},
onScreenShareEnded: () => {
if (screenShareVideoRef.current) {
screenShareVideoRef.current.srcObject = null;
}
},
});Compose the screen-share tile alongside the camera tiles in your custom layout.
Audio passthrough during screen share
By default, getDisplayMedia captures video only. To capture system audio (for sharing a video with sound), pass audio: true:
// Browser-level API — the SDK's startScreenShare wraps this internally
const stream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true,
});Browser support is uneven — Chrome desktop has it; Firefox does not; Safari has it for tab capture but not full-screen.
The Calls SDK's startScreenShare() does NOT request audio by default in v4. If you need audio passthrough, use the lower-level getDisplayMedia API directly + pipe the audio track via a custom track-add hook (covered in custom-ui.md).
---
Combining recording + screen-share
Server-side recording captures the active video composition, including screen-share when a participant is sharing. The recording file is one MP4 with the layout the kit was rendering at the time.
If you're using custom UI, the recording captures what the SDK sends to the server — not your custom DOM. The composition is determined by the SDK's internal layout, not your CSS.
---
Browser permissions for screen-share
Like getUserMedia, getDisplayMedia requires HTTPS or localhost. It also requires an active user gesture (click/tap) — you cannot start it from a useEffect or timer. The skill scaffolds the API call inside an onClick handler.
System-level: macOS 10.15+ asks the user once to grant Chrome/Safari/Firefox permission to record the screen (System Preferences → Security & Privacy → Screen Recording). If the user denies, getDisplayMedia throws NotAllowedError with no remediation path inside the browser — surface a "Open System Preferences" instruction.
---
Cleanup
Both recording and screen-share are part of the call session. CometChatCalls.leaveSession() stops both automatically. Custom UI must NOT separately call getTracks().forEach(t => t.stop()) on the SDK's screen-share stream — the SDK owns it. Stop only the streams YOUR code created (e.g. local preview).
Share invite on web (React)
Let participants share the call link with others. The kit ships a share-invite button (hidden by default); your app intercepts the click and runs the platform's native share UI.
Canonical docs: https://www.cometchat.com/docs/calls/javascript/share-invite
---
Hard rule: deep-link routing must work BEFORE you ship share-invite
A share button that copies a URL nobody can open is worse than no share button. Verify your app's /call/:sessionId route handles all four states:
1. User logged in, in-app → join call directly 2. User logged in, fresh tab → restore session, join call 3. User logged out → login, then redirect to call 4. User doesn't have account → signup flow with ?invite=... query param
Build the deep-link route first, then turn on the share button.
---
Show the kit's share button
const callSettings = {
hideShareInviteButton: false,
// ... rest
};---
Handle the click
useEffect(() => {
const handler = () => shareCallInvite(sessionId);
CometChatCalls.addEventListener("onShareInviteButtonClicked", handler);
return () => {
CometChatCalls.removeEventListener("onShareInviteButtonClicked", handler);
};
}, [sessionId]);---
Native Web Share API + clipboard fallback
async function shareCallInvite(sessionId: string) {
const url = `https://yourapp.com/call/${sessionId}`;
const shareData = {
title: "Join my call",
text: "I'm on a call — tap to join.",
url,
};
if (navigator.share && navigator.canShare?.(shareData)) {
try {
await navigator.share(shareData);
} catch (err) {
// User cancelled — that's fine, no toast needed
if ((err as Error).name !== "AbortError") {
console.warn("Share failed", err);
}
}
return;
}
// Fallback: clipboard
try {
await navigator.clipboard.writeText(url);
showToast("Link copied to clipboard");
} catch {
// Clipboard API requires user gesture + secure context (https) — must always have a manual fallback
promptManualCopy(url);
}
}navigator.share is iOS Safari + Android Chrome + recent desktop Chrome. navigator.clipboard.writeText is everywhere modern but only works in secure contexts (https) — localhost is fine; non-https staging hosts will throw.
---
Custom share dialog (when you want more control)
function ShareDialog({ sessionId, onClose }: Props) {
const url = `https://yourapp.com/call/${sessionId}`;
return (
<dialog open className="share-dialog" role="dialog" aria-label="Share call invite">
<button onClick={() => navigator.clipboard.writeText(url)}>Copy link</button>
<a href={`mailto:?subject=Join%20my%20call&body=${encodeURIComponent(url)}`}>Email</a>
<a href={`sms:?body=${encodeURIComponent(url)}`}>SMS</a>
<button onClick={onClose}>Close</button>
</dialog>
);
}mailto: opens default email client; sms: opens default SMS app on mobile (no-op on most desktops). Both are universal — no provider lock-in.
---
QR code for in-person sharing
For "share to the person sitting next to you" UX, a QR is faster than a link:
import QRCode from "qrcode.react";
function CallQR({ sessionId }: { sessionId: string }) {
const url = `https://yourapp.com/call/${sessionId}`;
return (
<div>
<QRCode value={url} size={192} />
<p>{url}</p>
</div>
);
}---
Anti-patterns
1. Sharing the raw `sessionId` instead of a deep link. Recipients can't open it. 2. Wiring share before the deep-link route works. Recipients click → 404. 3. `navigator.clipboard.writeText` without a manual-copy fallback. Fails in non-https contexts (e.g., embedded iframes, dev tunnels). 4. Showing "Link copied!" toast even when share was cancelled. AbortError is the user dismissing the share sheet — silent. 5. Forgetting `removeEventListener` on unmount. Listener accumulates → multiple share sheets per click. 6. Hard-coding `https://yourapp.com`. Use window.location.origin or import.meta.env.VITE_APP_URL.
---
Server-side: deep links + auth bridge
If invitee isn't logged in, https://yourapp.com/call/SESSION_ID should redirect them through login THEN to the call. Pattern:
// pages/call/[sessionId].tsx (Next.js example)
export async function getServerSideProps({ params, req }) {
const session = await getSession(req);
if (!session) {
return {
redirect: {
destination: `/login?next=/call/${params.sessionId}`,
permanent: false,
},
};
}
return { props: { sessionId: params.sessionId } };
}For invitees without an account, send them to /signup?invite=SESSION_ID and store the pending invite in localStorage until signup completes.
---
Verification checklist
- [ ] Deep-link route
/call/:sessionIdworks in 4 states (logged in / logged out / no account / fresh tab) - [ ]
hideShareInviteButton: falsein CallSettings - [ ]
onShareInviteButtonClickedlistener cleaned up on unmount - [ ]
navigator.shareused when available; clipboard fallback; manual-copy fallback for non-https - [ ] AbortError silenced
- [ ] App URL not hard-coded — read from env
- [ ] Smoke: copy link → paste in incognito → can join call
---
Pointers
cometchat-react-calls/SKILL.md— call architecturecometchat-react-calls/references/in-call-chat.md— sister cross-cutting concern- Canonical docs: https://www.cometchat.com/docs/calls/javascript/share-invite