Skip to main content

Chat Integration

In this tutorial we will guide you through the process of embedding video calling capabilities into a chat application. We will cover the following topics:

  • Boilerplate setup (clients instantiation, building blocks)
  • Initiating a ring call
  • Handling different call calling states (ringing, active call, call left etc.)
  • Handling different ring call events (call created, accepted, rejected and ended)
  • Handling multiple pending calls
  • Initiating group calls
  • Terminating a call (pending, active)

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 stream-chat-react stream-chat
yarn add @stream-io/video-react-sdk stream-chat-react stream-chat
tip

When implementing a ring call scenario as we do in this demo, it is important to have a good understanding of our ring call lifecycle. You can learn more about the topic in the Joinging & Creating Calls guide.

App boilerplate

We have prepared a demo application to accompany this guide. We do not aim to explain the whole demo application source code. The demo application will serve us to demonstrate the main concepts behind the video-in-chat integration.

note

To initiate chat and video clients you are encouraged to use the same API key. The user tokens should be generated with the same secret. There is no need to create separate apps for chat and video.

Initiating chat and video clients

import type { UserResponse } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import { StreamVideo, StreamVideoClient } from '@stream-io/video-react-sdk';
import { Channel } from './components/Channel';
import { Sidebar } from './components/Sidebar';
import { Video } from './components/Video';
import { useCreateChatClient } from './hooks';

import type { StreamChatType } from './types/chat';
import { useState } from 'react';

const Root = ({
apiKey,
user,
userToken,
}: {
apiKey: string;
user: UserResponse<StreamChatType>;
userToken: string;
}) => {
const chatClient = useCreateChatClient<StreamChatType>({
apiKey,
tokenOrProvider: userToken,
userData: user,
});
const [videoClient, setVideoClient] = useState<StreamVideoClient>();

useEffect(() => {
const _client = new StreamVideoClient({
apiKey,
user: userData,
token: userToken,
});
setVideoClient(_client);

return () => {
_client.disconnectUser();
setVideoClient(undefined);
};
}, []);

if (!chatClient || !videoClient) return null;

return (
<Chat client={chatClient}>
<StreamVideo client={videoClient}>
<Sidebar user={user} />
<Channel />
<Video />
</StreamVideo>
</Chat>
);
};

Initiating a ring call

In the ring call scenario we recommend to first create a call without immediately joining it. Use the Call method getOrCreate() to accomplish this. An example can be found in CreateCallButton component in the demo app:

import { useCallback } from 'react';
import {
MemberRequest,
} from '@stream-io/video-react-sdk';
import { useChannelStateContext, useChatContext } from 'stream-chat-react';
import { LocalPhone } from '@mui/icons-material';
import { meetingId } from '../../utils/meetingId';
import type { StreamChatType } from '../../types/chat';

export const CreateCallButton = () => {
const videoClient = /* ... */
const { client } = useChatContext<StreamChatType>();
const { channel } = useChannelStateContext<StreamChatType>();

const createCall = useCallback(() => {
videoClient?.call('default', meetingId()).getOrCreate({
ring: true,
data: {
custom: {
channelCid: channel.cid,
},
members: Object.values(channel.state.members).reduce<MemberRequest[]>(
(acc, member) => {
if (member.user_id !== client.user?.id) {
acc.push({
user_id: member.user_id!,
});
}
return acc;
},
[],
),
},
});
}, [videoClient, channel.cid, channel.state.members, client.user?.id]);

const disableCreateCall = !videoClient;
return (
<button
className="rmc__button rmc__button--green"
disabled={disableCreateCall}
onClick={createCall}
>
<LocalPhone />
</button>
);
};
note

There is a flexibility in what channel members will be included in the call. And so the call can be a 1:1 or a group call. In our implementation we include all channel members.

Handling the ring call states

Once a ring call is initiated, call members start to receive ring call events (call.created, call.accepted, call.rejected, call.ended) over the WebSocket maintained by the video client. The video client updates the calls pool state and calling state of individual affected Call in response to these events.

Observing the calls pool state

The array of all Call objects representing created pending (not accepted, rejected, neither ended) calls is continuously updated in response to each new call creation. You can use this array of Call objects to display incoming or outgoing calls in your application's UI. In our app the top-level Video component observes the changes using the useCalls hook and re-renders the UI to reflect the changes:

import { StreamCall, useCalls } from '@stream-io/video-react-sdk';

import { CallPanel } from './CallPanel';

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

To identify an outgoing call, use the call.isCreatedByMe flag.

In our demo app, we reflect the incoming calls state in channel list. Channel preview shows buttons to accept or reject the incoming call. The outgoing call or incoming call in an active channel is represented by a CallPanel component floating above the chat UI:

Image of incoming calls in channel preview
Incoming calls in channel preview

This is done by embedding custom component ChannelPreviewCallControls in a ChannelPreview component:

import {
AcceptCallButton,
CallingState,
CancelCallButton,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import { useChatContext } from 'stream-chat-react';

export const ChannelPreviewCallControls = () => {
const { channel: activeChannel } = useChatContext();
// the Call instance is passed down from StreamCallProvider located in StreamCall
const call = useCall();
const { useCallCallingState } = useCallStateHooks();
const callingState = useCallCallingState();

const callingToActiveChannel =
activeChannel && call && activeChannel.cid === call.state.custom.channelCid;

const isRinging = callingState === CallingState.RINGING;

if (call && isRinging && !callingToActiveChannel) {
return (
<div className="rmc__channel-preview__call-controls">
<AcceptCallButton
onClick={(e) => {
e.stopPropagation();
call.join();
}}
/>
<CancelCallButton
onClick={(e) => {
e.stopPropagation();
call.leave({ reject: true });
}}
/>
</div>
);
}
return null;
};

Observing the state of a specific call

Each call can pass through different states. The call calling state is made available through useCallCallingState hook. Therefore, our custom CallPanel component displays different UI (pending call, active call) based on the information provided by the hook:

import {
RingingCall,
CallingState,
CallParticipantsView,
useCall,
ScreenShareButton,
SpeakingWhileMutedNotification,
ToggleAudioPublishingButton,
ToggleVideoPublishingButton,
CancelCallButton,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import { useChatContext } from 'stream-chat-react';
import { useState } from 'react';
import { useDraggable } from '../../hooks';

export const CallPanel = () => {
const call = useCall();
const { useCallCallingState, useCallCustomData } = useCallStateHooks();
const callingState = useCallCallingState();
const customData = useCallCustomData();

const { channel: activeChannel } = useChatContext();
const [panelElement, setPanelElement] = useState<HTMLDivElement | null>(null);
useDraggable(panelElement);

if (!call) return null;

const callingToActiveChannel = activeChannel?.cid === customData.channelCid;

if (CallingState.RINGING === callingState && !callingToActiveChannel)
return null;

if (callingState === CallingState.JOINED) {
return (
<div
className="str-video__call-panel rmc__call-panel-wrapper"
ref={setPanelElement}
>
<CallParticipantsView call={call} />
<div className="rmc__active-call-controls">
<ScreenShareButton />
<SpeakingWhileMutedNotification>
<ToggleAudioPublishingButton />
</SpeakingWhileMutedNotification>
<ToggleVideoPublishingButton />
<CancelCallButton />
</div>
</div>
);
} else if (
[CallingState.RINGING, CallingState.JOINING].includes(callingState)
) {
return (
<div className="rmc__call-panel-wrapper" ref={setPanelElement}>
<RingingCall />
</div>
);
}

return null;
};

Terminating a call

What call termination means depends on the perspective:

  1. A user can reject an incoming pending call.
  2. A user can end / cancel own outgoing pending call.
  3. A call participant (who joined a call) can leave a call.

In case of the group call scenario, a single user rejecting an incoming call or participant leaving a call does not terminate the call for anybody else. However, a user who initiated a pending call will end it for everybody if it has not been joined by others yet. Call is ended also, if the last member rejected the incoming call.

A user who rejected or left a call can re-join the same call again as long as the call has not been ended. The call cannot be re-joined only if ended.

So depending on the context, we associate a bit different click handlers with the button, that terminates the call for the current user:

Reject an incoming call Our RingingCallControls attaches the following callback to the CancelCallButton button that rejects the call:

<CancelCallButton
onClick={() => {
call.leave({ reject: true });
}}
/>

End/Cancel an outgoing call & Leave already joined call The default callback of the CancelCallButton button is pure call.leave() without any parameters. Again the RingingCallControls as well as the demo's active call panel components make use of this by not overriding the default onClick:

<CancelCallButton />

Adding default styles

In order the default styles that come with both the chat and video SDKs can be applied, we import them into the file index.scss:

@layer default-chat-sdk {
@import 'stream-chat-react/dist/scss/v2/index.scss';
}

@import '@stream-io/video-react-sdk/dist/css/styles.css' layer(default-video-sdk);

File index.scss is then imported into App.tsx. Additionally, we add StreamTheme component to wrap the app in an element carrying CSS class str-video. This will make sure all the variables and styles are applied to the child components:

import type { UserResponse } from 'stream-chat';
import { Chat } from 'stream-chat-react';
import { StreamTheme, StreamVideo } from '@stream-io/video-react-sdk';
import { Channel } from './components/Channel';
import { Sidebar } from './components/Sidebar';
import { Video } from './components/Video';
import { useCreateChatClient } from './hooks';

import './styles/index.scss';

import type { StreamChatType } from './types/chat';

const Root = ({
apiKey,
user,
userToken,
}: {
apiKey: string;
user: UserResponse<StreamChatType>;
userToken: string;
}) => {
// create clients and connect users

return (
<StreamTheme as="main" className="main-container">
<Chat client={chatClient}>
<StreamVideo client={videoClient}>
<Sidebar user={user} />
<Channel />
<Video />
</StreamVideo>
</Chat>
{/*// highlight-next-line*/}
</StreamTheme>
);
};

Did you find this page helpful?