In the previous part, we added real-time messaging, rich text formatting, reactions, and file uploads to our Slack clone using the Stream React Chat SDK.
In this final part of our series, we’ll add video-calling capabilities to our Slack clone using the Stream React Video and Audio SDK. By the end of this part, users will be able to initiate and join video calls, similar to the "Huddle" feature in Slack.
Check out the live demo and the GitHub repository to see the final product.
Let’s get started!
Creating a Call Using the Huddle Button
First, we need a way for users to start and stop video calls easily. Let's create a button for this purpose, which we'll call the HuddleToggleButton
.
Go to the components
directory, and create a HuddleToggleButton.tsx
file with the following code:
import { useCallback, useContext } from 'react';
import {
Call,
CallingState,
OwnCapability,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import { useUser } from '@clerk/nextjs';
import clsx from 'clsx';
import { AppContext } from '../app/client/layout';
import CaretDown from './icons/CaretDown';
import Headphones from './icons/Headphones';
import { isEqual } from 'lodash';
interface HuddleToggleButton {
currentCall: Call | undefined;
}
const HuddleToggleButton = ({ currentCall }: HuddleToggleButton) => {
const { user } = useUser();
const { workspace, channel, setChannelCall } = useContext(AppContext);
const call = useCall();
const { useCallCallingState, useParticipantCount } = useCallStateHooks();
const callingState = useCallCallingState();
const participantCount = useParticipantCount();
const callActive = callingState === CallingState.JOINED;
const callAvailable =
participantCount > 0 && callingState !== CallingState.JOINED;
const leaveCall = useCallback(async (call: Call) => {
const canEndCall = call?.permissionsContext.hasPermission(
OwnCapability.END_CALL
);
if (canEndCall) {
await call?.endCall();
} else {
await call?.leave();
}
}, []);
const createCall = useCallback(async () => {
try {
if (
currentCall &&
currentCall.state.callingState === CallingState.JOINED
) {
await leaveCall(currentCall);
}
const currentMembers = workspace?.memberships.map((m) => ({
user_id: m.userId,
role: m.role!,
}));
const customData = {
channelId: channel?.id,
channelName: channel?.name,
createdBy: user?.fullName,
createdByUserImage: user?.imageUrl,
};
if (call?.permissionsContext.hasPermission(OwnCapability.UPDATE_CALL)) {
if (!isEqual(call?.state.custom, customData)) {
await call?.update({
custom: customData,
});
}
if (!isEqual(call?.state.members, currentMembers)) {
await call?.updateCallMembers({
update_members: currentMembers,
});
}
}
await call?.getOrCreate({
ring: true,
data: {
custom: customData,
members: currentMembers,
},
});
await call?.join();
setChannelCall(call!);
} catch (error) {
console.error(error);
}
}, [
channel,
call,
setChannelCall,
currentCall,
leaveCall,
user,
workspace?.memberships,
]);
const toggleCall = useCallback(async () => {
if (callActive) {
await leaveCall(call!);
} else {
await createCall();
}
}, [call, callActive, createCall, leaveCall]);
return (
<div
className={clsx(
'w-[59px] hidden sm:flex items-center ml-2 rounded-lg h-7 border border-[#797c814d] text-[#e8e8e8b3]',
callActive && 'bg-[#259b69] border-[#259b69]',
callAvailable && 'bg-[#063225] border-[#00553d]'
)}
>
<button
onClick={toggleCall}
className={clsx(
'px-2 h-[26px] rounded-l-lg',
callActive && 'hover:bg-[#259b69]',
callAvailable && 'hover:bg-[#10392d]',
!callActive && !callAvailable && 'hover:bg-[#25272b]'
)}
>
<Headphones
color={
callActive || callAvailable ? 'var(--primary)' : 'var(--icon-gray)'
}
/>
</button>
<div
className={clsx(
'h-5 w-[1px] bg-[#797c814d]',
callActive && 'bg-white',
callAvailable && 'bg-[#00553d]'
)}
/>
<button
className={clsx(
'w-5 h-[26px] rounded-r-lg',
callActive && 'hover:bg-[#259b69]',
callAvailable && 'hover:bg-[#10392d]',
!callActive && !callAvailable && 'hover:bg-[#25272b]'
)}
>
<CaretDown
color={
callActive || callAvailable ? 'var(--primary)' : 'var(--icon-gray)'
}
/>
</button>
</div>
);
};
export default HuddleToggleButton;
In the component above, we update the current state of the call, including creating a new call, updating the call details, joining the call, or leaving the call using the following functions:
createCall()
: This function creates a new call by setting the required data, including channel details and workspace members. If there's an existing call, it first leaves that call.toggleCall()
: This function toggles the state between joining and leaving the call, depending on whether the user is already in an active call.
We also use the useCallCallingState
and useParticipantCount
hooks to keep track of the current call state. The button updates its appearance and behavior based on whether a call is ongoing, available, or inactive.
Next, let's integrate the HuddleToggleButton
into the channel page.
Navigate to /client/[workspaceId]/[channelId]/page.tsx
and update it as follows:
...
import HuddleToggleButton from '@/components/HuddleToggleButton';
...
const Channel = ({ params }: ChannelProps) => {
...
return (
<div
...
>
{/* Toolbar */}
<div className="pl-4 pr-3 h-[49px] flex items-center flex-shrink-0 justify-between">
...
<div className="flex flex-none ml-auto items-center">
<button
className={...}
>
...
</button>
{channelCall && (
<StreamCall call={channelCall}>
<HuddleToggleButton currentCall={currentCall} />
</StreamCall>
)}
{!channelCall && (
<div className="w-[59px] flex items-center ml-2 rounded-lg h-7 border border-[#797c814d] text-[#e8e8e8b3]">
<button className="px-2 h-[26px] hover:bg-[#25272b] rounded-l-lg">
<Headphones color="var(--icon-gray)" />
</button>
<div className="h-5 w-[1px] bg-[#797c814d]" />
<button className="w-5 h-[26px] hover:bg-[#25272b] rounded-r-lg">
<CaretDown color="var(--icon-gray)" />
</button>
</div>
)}
<button className="...">
<MoreVert className="..." />
</button>
</div>
</div>
{/* Tab Bar */}
...
{/* Chat */}
...
</div>
);
};
export default Channel;
Here, we integrate the HuddleToggleButton
component with the channel toolbar. We also wrap it with the StreamCall
component to manage the call state.
Implementing a Custom Hook for Huddles
With the huddle button in place, let's move on to creating a custom hook for our huddle feature. This hook will help manage different aspects of a call, such as toggling the microphone, camera, and screen sharing. It will also track the current state of the call, like whether the user is joining, speaking, or sharing their screen.
Create a new file in the hooks
directory called useHuddle.tsx
and add the following code:
import {
CallingState,
hasScreenShare,
isPinned,
OwnCapability,
useCall,
useCallStateHooks,
} from '@stream-io/video-react-sdk';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
const useHuddle = () => {
const call = useCall();
const {
useCallCallingState,
useCallCustomData,
useCameraState,
useMicrophoneState,
useScreenShareState,
useParticipants,
} = useCallStateHooks();
const callingState = useCallCallingState();
const customData = useCallCustomData();
const {
microphone,
optimisticIsMute: isMicrophoneMute,
hasBrowserPermission: hasMicrophonePermission,
} = useMicrophoneState();
const {
camera,
optimisticIsMute: isCameraMute,
hasBrowserPermission: hasCameraPermission,
} = useCameraState();
const { screenShare } = useScreenShareState();
const participants = useParticipants();
const [participantInSpotlight] = participants;
const [width, setWidth] = useState(0);
const huddleRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!huddleRef.current) return;
const resizeObserver = new ResizeObserver((entries) => {
for (const entry of entries) {
setWidth(entry.contentRect.width);
}
});
resizeObserver.observe(huddleRef.current);
return () => {
resizeObserver.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [huddleRef.current]);
const leaveCall = useCallback(async () => {
const canEndCall = call?.permissionsContext.hasPermission(
OwnCapability.END_CALL
);
if (canEndCall) {
await call?.endCall();
} else {
await call?.leave();
}
}, [call]);
const isSpeakerLayout = useMemo(() => {
if (participantInSpotlight) {
return (
hasScreenShare(participantInSpotlight) ||
isPinned(participantInSpotlight)
);
}
return false;
}, [participantInSpotlight]);
const toggleMicrophone = async () => {
try {
await microphone.toggle();
} catch (error) {
console.error(error);
}
};
const toggleCamera = async () => {
try {
await camera.toggle();
} catch (error) {
console.error(error);
}
};
const toggleScreenShare = async () => {
try {
await screenShare.toggle();
} catch (error) {
console.error(error);
}
};
const buttonsDisabled = callingState === CallingState.JOINING;
return {
call,
callingState,
screenShare,
customData,
isMicrophoneMute,
hasMicrophonePermission,
isCameraMute,
hasCameraPermission,
leaveCall,
toggleMicrophone,
toggleCamera,
toggleScreenShare,
width,
huddleRef,
buttonsDisabled,
isSpeakerLayout,
};
};
export default useHuddle;
In the code above:
Managing Call States: The
useHuddle
hook uses several hooks from@stream-io/video-react-sdk
to manage different aspects of a call. It tracks the current call state, such as theCallingState
, whether the user is muted, if screen sharing is active, and if the user has permission to perform specific actions.Toggling Microphone, Camera, and Screen Share: The
toggleMicrophone
,toggleCamera
, andtoggleScreenShare
functions allow users to control their microphone, camera, and screen sharing during calls.Spotlight Layout: The
isSpeakerLayout
variable helps determine whether the spotlight layout should be used. This typically happens if one participant shares their screen or is pinned.Leave or End Call: The
leaveCall
function allows the user to leave the call. If the user has the required permission, they can also end the call for all participants.
Building and Showing a Call
So far, users can create and join calls, but now we want to add the UI for the calls themselves. In this section, we'll integrate the huddle UI components, allowing users to manage video calls within a workspace, similar to Slack's huddle feature.
Creating the Call Control Button
The first component we need is a call control button. This button will toggle features during a call, like muting the microphone, turning the camera on or off, and more.
Create a new CallControlButton.tsx
file in the components
directory, and add the following code:
import clsx from 'clsx';
import { IconButtonProps } from './IconButton';
type CallControlButtonProps = Omit<IconButtonProps, 'variant'> & {
active?: boolean;
};
const CallControlButton = ({
active = false,
className,
icon,
onClick,
title,
}: CallControlButtonProps) => {
return (
<button
title={title}
className={clsx(
'w-9 h-9 rounded-full inline-flex items-center justify-center',
active && 'bg-[#e0e0e0] hover:bg-[#e0e0e0] [&_path]:fill-[#101214]',
!active &&
'bg-[#f8f8f840] [&_path]:fill-[#e0e0e0cc] hover:bg-[#696a6b] [&_path]:hover:fill-white',
className
)}
onClick={onClick}
>
{icon}
</button>
);
};
export default CallControlButton;
The CallControlButton
component adjusts its styles based on whether it is active
or not. The button changes its background color and hover behavior to indicate the active state, helping users visually understand the button's current state.
Customizing the Participant View
The main component we’ll use in our video layouts is Stream’s ParticipantView
component. This component displays the participant’s video, plays their audio, shows their information, and provides action buttons for controls like pinning or muting.
However, the ParticipantView
default UI doesn't match our design, so we'll override it by creating our own custom UI using the ParticipantViewUI
prop.
Create a new file in the components
directory called ParticipantViewUI.tsx
and add the following code:
import {
ComponentProps,
ForwardedRef,
forwardRef,
ReactNode,
useState,
} from 'react';
import {
DefaultParticipantViewUIProps,
DefaultScreenShareOverlay,
hasAudio,
hasScreenShare,
MenuToggle,
ParticipantActionsContextMenu,
SpeechIndicator,
ToggleMenuButtonProps,
useParticipantViewContext,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import Keep from './icons/Keep';
import MicrophoneOff from './icons/MicrophoneOff';
import MoreVert from './icons/MoreVert';
const ParticipantViewUI = () => {
const { participant, trackType } = useParticipantViewContext();
const [showMenu, setShowMenu] = useState(false);
const { isLocalParticipant, isSpeaking } = participant;
const isScreenSharing = hasScreenShare(participant);
const hasAudioTrack = hasAudio(participant);
if (isLocalParticipant && isScreenSharing && trackType === 'screenShareTrack')
return (
<>
<DefaultScreenShareOverlay />
<ParticipantDetails />
</>
);
return (
<>
<ParticipantDetails />
{/* Speech Ring */}
<div
className={clsx(
isSpeaking &&
hasAudioTrack &&
'ring-[3px] ring-[#fff] shadow-[0_0_8px_5px_#fff]',
`absolute left-0 top-0 w-full h-full rounded-xl`
)}
/>
{/* Menu Overlay */}
<div
onMouseOver={() => {
setShowMenu(true);
}}
onMouseOut={() => setShowMenu(false)}
className={`absolute z-[1] left-0 top-0 w-full h-full rounded-xl bg-transparent`}
/>
{/* Menu */}
<div
onMouseOver={() => setShowMenu(true)}
className={clsx(
'z-[999] absolute top-2 right-2',
showMenu ? 'opacity-100' : 'opacity-0'
)}
>
<MenuToggle
strategy="fixed"
placement="bottom-start"
ToggleButton={OtherMenuToggleButton}
>
<ParticipantActionsContextMenu />
</MenuToggle>
</div>
</>
);
};
const ParticipantDetails = ({}: Pick<
DefaultParticipantViewUIProps,
'indicatorsVisible'
>) => {
const { participant } = useParticipantViewContext();
const { pin, name, userId, isSpeaking, isDominantSpeaker } = participant;
const hasAudioTrack = hasAudio(participant);
const pinned = !!pin;
return (
<>
<div className="z-1 absolute left-2 bottom-2 w-full pr-4 flex items-end justify-between cursor-default select-none">
<span
className={clsx(
'flex items-center h-7 gap-1 py-1 px-2 rounded-full text-[13px] leading-[1.38463] font-bold',
isSpeaking ? 'bg-[#fff] text-[#212428]' : 'bg-[#212428] text-white'
)}
>
{!hasAudioTrack && (
<span>
<MicrophoneOff size={20} />
</span>
)}
{hasAudioTrack && isSpeaking && (
<div className="w-6.5 h-6.5 flex items-center justify-center bg-primary rounded-full">
<SpeechIndicator />
</div>
)}
<span>{name || userId}</span>
</span>
{pinned && (
<div className="w-8 h-8 flex items-center justify-center bg-[#fff] rounded-lg">
<Keep />
</div>
)}
</div>
</>
);
};
const Button = forwardRef(function Button(
{
icon,
onClick = () => null,
...rest
}: {
icon: ReactNode;
onClick?: () => void;
} & ComponentProps<'button'> & { menuShown?: boolean },
ref: ForwardedRef<HTMLButtonElement>
) {
return (
<button
onClick={(e) => {
e.preventDefault();
onClick?.(e);
}}
{...rest}
ref={ref}
className="h-8 w-8 rounded-lg flex items-center justify-center bg-[#fff] hover:bg-[#fff] border-[#fff]"
>
{icon}
</button>
);
});
const OtherMenuToggleButton = forwardRef<
HTMLButtonElement,
ToggleMenuButtonProps
>(function ToggleButton(props, ref) {
return (
<Button {...props} title="More options" ref={ref} icon={<MoreVert />} />
);
});
export default ParticipantViewUI;
The ParticipantViewUI
is divided into several components:
Participant Details: The
ParticipantDetails
component shows details for each participant, such as their name and audio status, making it easy to identify who is currently speaking or pinned.Speech Indicator and Speech Ring: The
SpeechIndicator
component gives a clear visual cue to users when a participant is speaking. We also included a speech ring effect highlighting the participant's video when speaking.Screen Sharing: The
DefaultScreenShareOverlay
is used when a participant is sharing their screen, providing a visual indication to other participants.Context Menu: The menu allows participants to perform actions such as pinning or muting themselves or another participant.
Building the Huddle UI
With the main call controls and participant view in place, it's time to create the huddle interface. This UI will contain the video feed layout and call controls and ensure the interface responds dynamically to different situations, like toggling between the sidebar and modal views.
Create a new file in the components
directory called HuddleUI.tsx
and add the following code:
import {
StreamTheme,
PaginatedGridLayout,
SpeakerLayout,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';
import CallControlButton from './CallControlButton';
import Desktop from './icons/Desktop';
import Emoji from './icons/Emoji';
import IconButton from './IconButton';
import Microphone from './icons/Microphone';
import MicrophoneOff from './icons/MicrophoneOff';
import MoreVert from './icons/MoreVert';
import OpenInWindow from './icons/OpenInWindow';
import ParticipantViewUI from './ParticipantViewUI';
import PopBack from './icons/PopBack';
import Signal from './icons/Signal';
import UserAdd from './icons/UserAdd';
import Video from './icons/Video';
import VideoOff from './icons/VideoOff';
import useHuddle from '../hooks/useHuddle';
interface HuddleUIProps {
isModalOpen?: boolean;
setIsModalOpen: (isOpen: boolean) => void;
isSidebar?: boolean;
}
const HuddleUI = ({
isSidebar = false,
isModalOpen = false,
setIsModalOpen,
}: HuddleUIProps) => {
const {
customData,
isMicrophoneMute,
isCameraMute,
hasMicrophonePermission,
hasCameraPermission,
leaveCall,
toggleMicrophone,
toggleCamera,
toggleScreenShare,
width,
huddleRef,
buttonsDisabled,
screenShare,
isSpeakerLayout,
} = useHuddle();
const enableScreenShare = async () => {
try {
setIsModalOpen(true);
await screenShare.enable();
} catch (error) {
console.error(error);
}
};
const windowToggle = () => {
if (isSidebar) {
setIsModalOpen(true);
} else {
setIsModalOpen(false);
}
};
return (
<StreamTheme className={clsx('huddle', isSidebar && 'huddle--sidebar')}>
<div
className={clsx(
'w-full',
isSidebar && 'absolute pr-4 bottom-2 left-2',
!isSidebar && 'block'
)}
>
<div
ref={huddleRef}
className={clsx(
'bg-theme-gradient flex flex-col w-full rounded-xl',
isSidebar && 'max-w-[340px] max-h-[340px]',
!isSidebar && 'h-full'
)}
>
<div className="flex flex-col px-1 items-center justify-center">
<div className="flex my-2 pr-2 w-full items-center justify-start">
<div className="flex items-center mr-auto">
<div className="ml-1 mr-1">
<Signal />
</div>
<button className="w-full flex items-center text-[14.8px] hover:underline">
<span className="break-all whitespace-break-spaces line-clamp-1">
{isModalOpen && isSidebar
? 'Viewing in another window'
: customData?.channelName}
</span>
</button>
</div>
<IconButton
icon={
isModalOpen ? (
<PopBack />
) : (
<OpenInWindow className="fill-icon-gray group-hover:fill-white" />
)
}
className="w-[30px] h-[30px]"
onClick={windowToggle}
disabled={isModalOpen && isSidebar}
/>
</div>
</div>
{((!isModalOpen && isSidebar) || (isModalOpen && !isSidebar)) && (
<div
className={clsx(
'grow shrink-0 mx-1 relative flex items-center justify-center overflow-hidden',
isSidebar && 'h-[240px]',
!isSidebar && 'h-[calc(45svw-100px)] max-h-[620px]'
)}
>
<div className="relative z-10 flex items-center justify-center w-full">
{(!isSpeakerLayout || (isSpeakerLayout && isSidebar)) && (
<PaginatedGridLayout
groupSize={isSidebar ? 2 : 6}
ParticipantViewUI={ParticipantViewUI}
/>
)}
{isSpeakerLayout && !isSidebar && (
<SpeakerLayout
ParticipantViewUIBar={ParticipantViewUI}
ParticipantViewUISpotlight={ParticipantViewUI}
participantsBarLimit={3}
participantsBarPosition="bottom"
/>
)}
</div>
<div className="absolute w-full h-full overflow-hidden rounded-lg">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src="https://a.slack-edge.com/27f87ff/img/huddles/gradient_01.png"
alt="huddle-background"
className="w-full h-full object-cover"
/>
</div>
</div>
)}
<div
className={clsx(
'flex items-center h-[52px] px-2',
isSidebar ? 'justify-between gap-2' : 'justify-center gap-4'
)}
>
<div
className={clsx(
'flex items-center',
isSidebar ? 'gap-1.5' : 'gap-2'
)}
>
<CallControlButton
icon={
isMicrophoneMute ? (
<MicrophoneOff size={20} />
) : (
<Microphone size={20} />
)
}
title={isMicrophoneMute ? 'Unmute mic' : 'Mute mic'}
active={!isMicrophoneMute}
onClick={toggleMicrophone}
disabled={!hasMicrophonePermission || buttonsDisabled}
/>
{width > 0 && width >= 220 && (
<CallControlButton
icon={
isCameraMute ? <VideoOff size={20} /> : <Video size={20} />
}
title={isCameraMute ? 'Turn on video' : 'Turn off video'}
active={!isCameraMute}
onClick={toggleCamera}
disabled={!hasCameraPermission || buttonsDisabled}
/>
)}
{width > 0 && width >= 260 && (
<CallControlButton
icon={<Desktop size={20} />}
onClick={isSidebar ? enableScreenShare : toggleScreenShare}
title={'Share screen'}
disabled={buttonsDisabled}
/>
)}
{width > 0 && width >= 300 && (
<CallControlButton
icon={<Emoji size={20} />}
title={'Send a reaction'}
/>
)}
{width > 0 && width >= 340 && (
<CallControlButton
icon={<UserAdd size={20} />}
title={'Invite people'}
/>
)}
<CallControlButton
icon={<MoreVert size={20} />}
title={'More options'}
/>
</div>
<button
onClick={() => {
leaveCall();
setIsModalOpen(false);
}}
disabled={buttonsDisabled}
className="flex items-center justify-center min-w-[64px] h-[36px] px-3 pb-[1px] text-[13px] border border-[#b41541] bg-[#b41541] hover:shadow-[0_1px_4px_#0000004d] hover:bg-blend-lighten hover:bg-[linear-gradient(#d8f5e914,#d8f5e914)] font-bold select-none text-white rounded-lg disabled:bg-gray-400 disabled:text-white disabled:border-gray-400"
>
Leave
</button>
</div>
</div>
</div>
</StreamTheme>
);
};
export default HuddleUI;
In the code snippet above:
Layouts: We use the
PaginatedGridLayout
andSpeakerLayout
to arrange participants based on the context (e.g., sidebar vs. full screen).Control Buttons: We use the
CallControlButton
component to toggle the microphone, camera, and screen sharing.Modal Window Handling: We use the
isSidebar
andisModalOpen
props to handle when the huddle is open in another window or embedded in the sidebar.
Huddle Component
Now that we've developed the core user interface for our video calls, it's time to integrate the functionality into a single cohesive component. The huddle component will be responsible for managing the different states of a video call, such as when a user is joining, leaving, or responding to an invitation.
Create a new file in the components
directory called Huddle.tsx
and add the following code:
import { useContext } from 'react';
import { CallingState, StreamTheme } from '@stream-io/video-react-sdk';
import { AppContext } from '../app/client/layout';
import Avatar from './Avatar';
import Hash from './icons/Hash';
import HuddleUI from './HuddleUI';
import useHuddle from '../hooks/useHuddle';
interface HuddleProps {
isModalOpen: boolean;
setIsModalOpen: (isOpen: boolean) => void;
}
const Huddle = ({ isModalOpen, setIsModalOpen }: HuddleProps) => {
const { call, customData, callingState, buttonsDisabled } = useHuddle();
const { channel } = useContext(AppContext);
const joinCall = () => {
call?.join();
};
const declineCall = () => {
call?.leave({
reject: true,
});
};
if (!call) return null;
switch (true) {
case callingState === CallingState.RINGING &&
call.isCreatedByMe &&
call.id === channel.id:
default:
return null;
case callingState === CallingState.JOINED ||
callingState === CallingState.JOINING:
return (
<HuddleUI
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
isSidebar
/>
);
case callingState === CallingState.RINGING && !call.isCreatedByMe:
return (
<StreamTheme>
<div className="absolute pr-4 bottom-2 left-2 w-full">
<div className="w-full max-w-[340px] p-3 bg-[#101214] rounded-xl overflow-hidden border border-[#797c814d]">
<div className="flex items-start text-[15px]">
<div className="grow shrink-0 mr-2">
<Avatar
width={20}
borderRadius={6}
data={{
name: customData.createdBy,
image: customData.createdByUserImage,
}}
/>
</div>
<p className="-mt-0.5">
<b>{customData.createdBy}</b> is inviting you to a huddle in
<span className="w-[15px] h-[15px] inline-block pt-0.5 mx-1">
<Hash size={15} color="var(--primary)" />
{' '}
</span>
<b>{customData.channelName}</b>
</p>
</div>
<div className="mt-3 flex flex-col items-center gap-1.5">
<button
onClick={joinCall}
disabled={buttonsDisabled}
className="w-full h-[26px] rounded-lg border border-[#e0e0e0] bg-[#e0e0e0] px-3 text-[13px] text-[#101214] font-bold"
>
Join
</button>
<button
onClick={declineCall}
disabled={buttonsDisabled}
className="w-full h-[26px] rounded-lg border border-[#f8f8f840] bg-[#f8f8f840] hover:bg-[#696a6b] hover:border-[#696a6b] px-3 text-[13px] text-[#e0e0e0cc] font-bold"
>
Decline
</button>
</div>
</div>
</div>
</StreamTheme>
);
}
};
export default Huddle;
In the snippet above:
State Handling: The
callingState
from theuseHuddle
hook is used to determine the appropriate UI to display. For example, if the call is ringing and wasn't initiated by the current user (call.isCreatedByMe
), an invitation message, and options to join or decline the call are shown.Joining and Declining a Call: The
joinCall
anddeclineCall
functions handle the respective actions. ThejoinCall
function invokes thecall.join()
method, while thedeclineCall
function invokescall.leave()
with the option to reject.Conditional Rendering: Depending on the call state, the component renders different UI sections, including:
The invitation view, when the call is
RINGING
but not initiated by the user.The call UI when the user has
JOINED
or isJOINING
.
Huddle Modal Component
The huddle modal component will give an expanded view of the video call interface. It provides a more immersive experience for users when compared to the sidebar view.
Create a new file in the components
directory called HuddleModal.tsx
and add the following code:
import HuddleUI from './HuddleUI';
interface HuddleModalProps {
isModalOpen: boolean;
setIsModalOpen: (isModalOpen: boolean) => void;
}
const HuddleModal = ({ isModalOpen, setIsModalOpen }: HuddleModalProps) => {
if (!isModalOpen) return null;
return (
<div className="z-[9999] fixed left-0 top-0 flex h-full min-h-screen w-full items-center justify-center bg-[#0009] px-4 py-5">
<div className="flex flex-col w-[80svw] max-w-[1280px] h-[45svw] max-h-[720px] rounded-lg bg-[#1a1d21] border border-[#797c8126] shadow-[0_0_0_1px_rgba(29,28,29,.13),0_18px_48px_0_#00000059]">
<HuddleUI isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} />
</div>
</div>
);
};
export default HuddleModal;
The component accepts an isModalOpen
prop to control when to display the modal. The modal also uses fixed positioning and includes styles that provide a centered, full-screen overlay.
Putting It All Together
With the Huddle
and HuddleModal
now created, we can add them to our app.
Normally, we could place our Huddle
component in the Sidebar
. However, the Sidebar
is a child component in our layout.tsx
file, and layout files persist across routes and maintain the same state.
This feature would be unsuitable for our needs as we want the current call state to be able to reset and effects to re-run each time a user navigates between channels.
So instead, we'll use a template file. Templates in Next.js are similar to layouts in that they wrap a child layout or page. However, unlike layouts, they create a new instance of their children each time the user navigates.
This behavior makes templates perfect for our huddle feature, as it allows us to reset the state and re-synchronize effects seamlessly when the channel or call data changes.
In the [channelId]
directory, create a new template.tsx
file with the following code:
'use client';
import { useContext, useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import { StreamCall, useCalls } from '@stream-io/video-react-sdk';
import { AppContext } from '../../layout';
import Huddle from '@/components/Huddle';
import HuddleModal from '@/components/HuddleModal';
export default function Template({ children }: { children: React.ReactNode }) {
const [currentCall] = useCalls();
const { channelCall } = useContext(AppContext);
const [isModalOpen, setIsModalOpen] = useState(false);
const huddleCall = useMemo(() => {
switch (true) {
case !!currentCall && !!channelCall:
return currentCall.id === channelCall.id ? channelCall : currentCall;
case !!currentCall:
return currentCall;
case !!channelCall:
return channelCall;
default:
return undefined;
}
}, [currentCall, channelCall]);
const sidebar = document.getElementById('sidebar');
const body = document.querySelector('body');
return (
<>
{children}
{sidebar &&
huddleCall &&
createPortal(
<StreamCall call={huddleCall}>
<Huddle isModalOpen={isModalOpen} setIsModalOpen={setIsModalOpen} />
</StreamCall>,
sidebar
)}
{body &&
huddleCall &&
createPortal(
<StreamCall call={huddleCall}>
<HuddleModal
isModalOpen={isModalOpen}
setIsModalOpen={setIsModalOpen}
/>
</StreamCall>,
body
)}
</>
);
}
In the code above:
useCalls Hook: This hook allows us to get the active call. We use it along with the
channelCall
from our app context to determine which call to display.Getting the Current Call: We use the
useMemo
hook to decide the currently active call, giving priority to matching calls from both the current call and channel call.Portal for Rendering: The
createPortal
function fromreact-dom
is used to render the Huddle and HuddleModal components to specific parts of the DOM—the sidebar and body. This allows us to dynamically position the components and ensure they integrate smoothly with the UI layout.StreamCall
Wrapper: TheStreamCall
wrapper is used to pass the active call to our Huddle components, allowing them to manage the video and audio streams effectively.
Before wrapping things up, let's add some important styles to our globals.css
file to ensure our huddle interface looks just right:
...
@layer components {
...
.huddle .str-video__participant-view,
.huddle
.str-video__participant-view.str-video__participant-view--dominant-speaker {
max-width: 500px;
}
.huddle .str-video__video-placeholder .str-video__video-placeholder__avatar {
width: 100%;
height: 100%;
border-radius: 12px;
}
.huddle.huddle--sidebar
.str-video__paginated-grid-layout
.str-video__paginated-grid-layout__group:has(
> .str-video__participant-view:first-child:nth-last-child(2)
) {
flex-direction: column;
height: 220px;
}
.huddle
.str-video__paginated-grid-layout--two-four
.str-video__participant-view {
max-width: calc(calc(720px - 6rem) / 2 - 6px);
}
.huddle
.str-video__speech-indicator.str-video__speech-indicator--dominant
.str-video__speech-indicator__bar,
.huddle .str-video__speech-indicator .str-video__speech-indicator__bar {
background-color: #42b659 !important;
width: 3px !important;
border-radius: 999px !important;
}
.huddle .str-video__menu-container {
background: #222529;
box-shadow: 0 1px 2px 0 rgba(0, 0, 0, 0.3),
0 2px 6px 2px rgba(0, 0, 0, 0.15);
border-radius: 4px;
width: 250px;
}
.huddle .str-video__generic-menu {
padding: 8px 0;
gap: 4px;
}
.huddle .str-video__generic-menu--item {
background: transparent;
border-radius: 0;
padding: 0;
height: 28px;
}
.huddle .str-video__generic-menu .str-video__generic-menu--item button {
border-radius: 0;
background: transparent;
color: #e8eaed;
padding: 0 16px;
height: 100%;
line-height: 1.25rem;
font-size: 0.875rem;
font-weight: 400;
}
.huddle .str-video__generic-menu .str-video__generic-menu--item button:hover {
background: #37383b;
}
.huddle .str-video__screen-share-overlay {
z-index: 10;
}
.huddle
.str-video__screen-share-overlay
.str-video__screen-share-overlay__title {
font-size: 18px;
color: var(--primary);
}
@media (max-width: 1280px) {
.huddle .str-video__participant-view {
max-width: 400px;
}
.huddle
.str-video__participant-view.str-video__participant-view--dominant-speaker {
max-width: 350px;
}
.huddle
.str-video__paginated-grid-layout--five-nine
.str-video__participant-view {
max-width: calc(calc(520px - 6rem) / 2 - 6px);
}
.huddle
.str-video__paginated-grid-layout--two-four
.str-video__participant-view {
max-width: calc(calc(580px - 6rem) / 2 - 6px);
}
}
@media (max-width: 1024px) {
.huddle .str-video__participant-view {
max-width: 300px;
}
.huddle
.str-video__participant-view.str-video__participant-view--dominant-speaker {
max-width: 250px;
}
.huddle
.str-video__paginated-grid-layout--five-nine
.str-video__participant-view {
max-width: calc(calc(380px - 6rem) / 2 - 6px);
}
.huddle
.str-video__paginated-grid-layout--two-four
.str-video__participant-view {
max-width: calc(calc(450px - 6rem) / 2 - 6px);
}
.huddle
.str-video__speaker-layout
.str-video__speaker-layout__participants-bar-wrapper
.str-video__speaker-layout__participants-bar
.str-video__speaker-layout__participant-tile {
max-width: 220px;
min-width: 220px;
width: 220px;
}
}
@media (max-width: 768px) {
.huddle
.str-video__paginated-grid-layout--two-four
.str-video__participant-view {
max-width: calc(calc(400px - 6rem) / 2 - 6px);
}
.huddle
.str-video__participant-view.str-video__participant-view--dominant-speaker {
max-width: 200px;
}
.huddle
.str-video__speaker-layout
.str-video__speaker-layout__participants-bar-wrapper
.str-video__speaker-layout__participants-bar
.str-video__speaker-layout__participant-tile {
max-width: 180px;
min-width: 180px;
width: 180px;
}
}
}
With this, our huddles are fully functional, allowing users to collaborate seamlessly during video calls!
Conclusion
With this final step, we've successfully built a Slack clone with video calling features using Stream React Video and Audio SDK.
We've covered a lot in this three-part series, from setting up the foundation of our Slack clone with a database, authentication, and initial pages, to adding real-time messaging and video calling.
I encourage you to explore Stream SDKs further and extend your app even more. You could add features like threads, typing indicators, live call reactions, and many more.
Feel free to check out the live demo and GitHub repository to see everything in action and explore the code.