Ringing Call

When building an application that relies on ring call workflow, you’ll often want to build your own incoming or outgoing call panels. The React SDK already comes with a pre-built RingingCall component. However, in this article we will build a new one from scratch.

The component will render:

  1. Call controls buttons to toggle audio and video enablement and accept resp. reject call buttons (the component will be called CallControls)
  2. Avatars of called users or the current user’s video preview (the component will be called CallMembers)
  3. Call calling state indicator (the component will be called CallCallingStateLabel)

Project setup and prerequisites

Make sure you have the following prerequisites checked:

  1. Registered Stream account
  2. Have an app created in the Stream’s dashboard to obtain app API key and secret.
  3. Initiate the project (you can follow our introductory tutorial setup guide)
  4. Have installed the Stream video and chat SDKs in the project:
npm install @stream-io/video-react-sdk
yarn add @stream-io/video-react-sdk

App boilerplate

In order we can start performing calls we need a StreamVideoClient instance connected to the Stream’s server network with app credentials. For more information on how to do that, please take a look at our authentication guide.

Ringing call panel implementation

The component will rely on data accessed via our helper hooks:

  • useCall
  • useCallCallingState
  • useCallCreatedBy
  • useCallMembers

User preview

The panel will render VideoPreview so that the user can see the camera input or avatars if video preview is disabled. The avatar display logic is as follows:

  • show all the called users’ avatars if the call is outgoing (who am I calling)
  • show the avatar of the person who initiated the call in case of an incoming call (who is calling me)
import {
  useCall,
  useCallStateHooks,
  VideoPreview,
  UserResponse,
} from "@stream-io/video-react-sdk";

import { CallCallingStateLabel } from "./CallCallingStateLabel";
import { CallControls } from "./CallControls";
import { CallMembers } from "./CallMembers";
import { useEffect } from "react";

type RingingCallProps = {
  showMemberCount?: number;
};

export const CustomRingingCall = ({
  showMemberCount = 3,
}: RingingCallProps) => {
  const call = useCall();
  const { useCallMembers, useCallCreatedBy, useCameraState } =
    useCallStateHooks();

  const members = useCallMembers();
  const creator = useCallCreatedBy();

  const { camera, isMute: isCameraMute } = useCameraState();
  useEffect(() => {
    // enable the camera by default for all ring calls
    camera.enable();
  }, [camera]);

  if (!call) return null;

  const caller = creator;
  // show the caller if this is an incoming call or show all the users I am calling to
  let membersToShow: UserResponse[] = [];
  if (call.isCreatedByMe) {
    membersToShow =
      members
        ?.slice(0, showMemberCount)
        .map(({ user }) => user)
        .filter((u) => !!u) || [];
  } else if (caller) {
    membersToShow = [caller];
  }

  return (
    <div>
      {isCameraMute ? (
        <CallMembers members={membersToShow} />
      ) : (
        <VideoPreview />
      )}
      <CallCallingStateLabel />
      <CallControls />
    </div>
  );
};

Incoming call panel

In the snippet below, the CallPanel component is expected to render our CustomRingingCall.

import {
  CallingState,
  StreamCall,
  useCall,
  useCallStateHooks,
  useCalls,
} from "@stream-io/video-react-sdk";

import { CustomActiveCallPanel } from "./CustomActiveCallPanel";
import { CustomRingingCall } from "./CustomRingingCall";

export const Video = () => {
  const calls = useCalls();
  return (
    <>
      {calls.map((call) => (
        <StreamCall call={call} key={call.cid}>
          <CallPanel />
        </StreamCall>
      ))}
    </>
  );
};

// custom component that renders ringing as well as active call UI
const CallPanel = () => {
  const call = useCall();
  const { useCallCallingState } = useCallStateHooks();
  const callingState = useCallCallingState();

  if (!call) return null;

  if (callingState === CallingState.JOINED) {
    return <CustomActiveCallPanel />;
  } else if (
    [CallingState.RINGING, CallingState.JOINING].includes(callingState)
  ) {
    return <CustomRingingCall />;
  }

  return null;
};

User avatars

We display relevant users’ avatars with CallMembers component.

import { Avatar } from "@stream-io/video-react-sdk";

import type { UserResponse } from "@stream-io/video-react-sdk";

type CallMembersProps = {
  members: UserResponse[];
};
const CallMembers = ({ members }: CallMembersProps) => {
  return (
    <div>
      {members.map((member) => (
        <div key={member.id}>
          <Avatar name={member.name} imageSrc={member.image} />
          {member.name && (
            <div>
              <span>{member.name}</span>
            </div>
          )}
        </div>
      ))}
    </div>
  );
};

Call calling state label

To show to our users the state of call connection, we implement CallCallingStateLabel. The calling state is provided by useCallCallingState hook:

In this component we are using translation service that comes bundled with the SDK. The service API is consumed with useI18n hook. Learn more about using the i18n service in the dedicated documentation.

import {
  CallingState,
  useCallStateHooks,
  useI18n,
} from "@stream-io/video-react-sdk";

const CALLING_STATE_TO_LABEL: Record<CallingState, string> = {
  [CallingState.JOINING]: "Joining",
  [CallingState.RINGING]: "Ringing",
  [CallingState.RECONNECTING]: "Re-connecting",
  [CallingState.RECONNECTING_FAILED]: "Failed",
  [CallingState.OFFLINE]: "No internet connection",
  [CallingState.IDLE]: "",
  [CallingState.UNKNOWN]: "",
  [CallingState.JOINED]: "Joined",
  [CallingState.LEFT]: "Left call",
};

const CallCallingStateLabel = () => {
  const { t } = useI18n();
  const { useCallCallingState } = useCallStateHooks();
  const callingState = useCallCallingState();
  const callingStateLabel = CALLING_STATE_TO_LABEL[callingState];

  return callingStateLabel ? <div>{t(callingStateLabel)}</div> : null;
};

Call controls

Finally, we build the call control buttons. We include buttons to toggle camera and microphone and buttons to accept and reject the call.

import {
  AcceptCallButton,
  CallingState,
  CancelCallButton,
  IconButton,
  useCall,
  useCallStateHooks,
} from "@stream-io/video-react-sdk";

const CallControls = () => {
  const call = useCall();
  const { useCallCallingState } = useCallStateHooks();
  const callingState = useCallCallingState();

  if (!call) return null;
  // show ringing call panel components only before the call is joined
  if (![CallingState.RINGING, CallingState.JOINING].includes(callingState))
    return null;

  // prevent users from rejecting call when already accepted
  const buttonsDisabled = callingState === CallingState.JOINING;

  return (
    <div>
      <ToggleAudioButton />
      <ToggleVideoButton />
      {call.isCreatedByMe ? (
        <CancelCallButton call={call} disabled={buttonsDisabled} />
      ) : (
        <>
          <AcceptCallButton call={call} disabled={buttonsDisabled} />
          <CancelCallButton
            onClick={() => {
              const reason = call.isCreatedByMe ? "cancel" : "decline";
              call.leave({ reject: true, reason });
            }}
            disabled={buttonsDisabled}
          />
        </>
      )}
    </div>
  );
};

const ToggleAudioButton = () => {
  const { useMicrophoneState } = useCallStateHooks();
  const { microphone, isMute } = useMicrophoneState();
  return (
    <IconButton
      icon={isMute ? "mic-off" : "mic"}
      onClick={() => microphone.toggle()}
    />
  );
};

const ToggleVideoButton = () => {
  const { useCameraState } = useCallStateHooks();
  const { camera, isMute } = useCameraState();
  return (
    <IconButton
      icon={isMute ? "camera-off" : "camera"}
      onClick={() => camera.toggle()}
    />
  );
};
© Getstream.io, Inc. All Rights Reserved.