Building a Slack Clone with Next.js and TailwindCSS - Part Three

Building a Slack Clone with Next.js and TailwindCSS - Part Three

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 the CallingState, 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, and toggleScreenShare 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 and SpeakerLayout 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 and isModalOpen 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 the useHuddle 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 and declineCall functions handle the respective actions. The joinCall function invokes the call.join() method, while the declineCall function invokes call.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 is JOINING.

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 from react-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: The StreamCall 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.