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

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

In the previous part, we built the chat interface for our Telegram clone and implemented features like rich text formatting and file uploads using the Stream React Chat SDK.

In this final part of the series, we’ll implement a call interface using Stream React Video and Audio SDK. This feature will enable users to have real-time voice and video conversations through direct messages and group chats.

You can check out the live demo and GitHub repository for the complete code.

Let’s get started!

Building the Call Modal UI

Telegram Web uses a modal to handle calls, so we’ll do the same for our clone. This interface is where users see the incoming call and interact with it while it’s ongoing.

Our first step is to handle calls while they’re in a “ringing”, “joining”, or “idle” state. We can easily create and derive the state and other information for any particular call using Stream’s Call object.

Create a CallModalUI.tsx file in the components folder and add the following code:

import { useMemo } from 'react';
import {
  CallingState,
  useCall,
  useCallStateHooks,
  MemberRequest,
} from '@stream-io/video-react-sdk';
import { useUser } from '@clerk/nextjs';

import RippleButton from './RippleButton';

type CallModalUIProps = {
  onClose: () => void;
};

const CallModalUI = ({ onClose }: CallModalUIProps) => {
  const call = useCall();
  const {
    useCallCallingState,
    useCallCustomData,
  } = useCallStateHooks();
  const callingState = useCallCallingState();
  const customData = useCallCustomData();
  const { user } = useUser();

  const buttonsDisabled = callingState === CallingState.JOINING;

  const joinCall = () => {
    call?.join();
  };

  const endCall = () => {
    if (customData.isDMChannel) {
      call?.endCall();
    } else {
      call?.leave({
        reject: true,
      });
    }
  };

  const chatName = useMemo(() => {
    if (customData.isDMChannel) {
      const member = customData.members.find(
        (member: MemberRequest) => member.user_id !== user?.id
      );
      return member?.name;
    } else {
      return customData.channelName;
    }
  }, [customData, user]);

  const callTriggeredByMe = customData.triggeredBy === user?.id;

  if (!call) return null;

  if (
    [CallingState.RINGING, CallingState.JOINING, CallingState.IDLE].includes(
      callingState
    )
  ) {
    return (
      <div className="absolute px-3.5 py-1.5 top-0 left-0 flex flex-col w-full h-full bg-ringing-gradient rounded-xl overflow-hidden border border-[#797c814d]">
        <div className="flex items-center select-none text-white">
          <div className="[&>button]:text-white [&>button]:w-[2.75rem] [&>button]:h-[2.75rem]">
            <RippleButton icon="fullscreen" />
          </div>
          <div
            onClick={onClose}
            className="ml-auto [&>button]:text-white [&>button]:w-[2.75rem] [&>button]:h-[2.75rem]"
          >
            <RippleButton icon="close" />
          </div>
        </div>
        <div className="flex flex-col mt-20 text-white items-center justify-center overflow-hidden">
          <h1 className="text-3xl font-medium truncate whitespace-pre">
            {chatName}
          </h1>
          <span className="mt-1">
            {callingState === CallingState.RINGING && !callTriggeredByMe
              ? 'ringing...'
              : 'waiting...'}
          </span>
        </div>
        <div className="mt-auto mb-4 w-full flex items-center justify-center gap-4">
          {callingState === CallingState.RINGING && !callTriggeredByMe && (
            <button
              onClick={joinCall}
              disabled={buttonsDisabled}
              className="w-[56px] h-[56px] flex items-center justify-center rounded-full border border-[#5cc85e] bg-[#5cc85e] text-[24px] text-white"
            >
              <i className="icon icon-phone-discard rotate-[-135deg]" />
            </button>
          )}
          <button
            onClick={endCall}
            disabled={buttonsDisabled}
            className="w-[56px] h-[56px] flex items-center justify-center text-[24px] rounded-full border border-[#ff595a] bg-[#ff595a] text-[white]"
          >
            <i className="icon icon-phone-discard" />
          </button>
        </div>
      </div>
    );
  }

  return null;
};

export default CallModalUI;

Let’s break down what’s going on here:

  • Call Setup and State:

    • We use useCall and useCallStateHooks from the Stream Video SDK to get the current Call instance and its state.

    • The callingState tells us if the call is ringing, joining, idle, or joined, while customData contains extra information like the channel name.

  • Button Behavior:

    • Buttons are disabled when the call is in the "joining" state to prevent multiple actions.

    • The joinCall function triggers call?.join(), which allows the user to answer the call.

    • The endCall function uses call?.endCall() for one-on-one calls (direct messages) and call?.leave({ reject: true }) for group calls, ensuring the correct behavior depending on the call type.

  • Conditional Rendering:

    • The component returns null if there isn’t a valid call instance.

    • The modal is only rendered when the call is in a state that makes sense for user interaction (ringing, joining, or idle).

  • User Interface Layout:

    • A header includes a control to close the modal using the RippleButton component.

    • The main body displays the channel name and call status (showing either "ringing..." or "waiting..." based on the call state).

    • Action buttons are provided to join or reject the call, styled appropriately for clarity.

Building the Calls Component

Next, we need a container to manage and display active calls within the chat interface. This component will listen for active calls and render the modal when needed.

Inside the components directory, create a Calls.tsx file with the following code:

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

import CallModalUI from './CallModalUI';

type CallsProps = {
  isModalOpen: boolean;
  onClose: () => void;
};

const Calls = ({ isModalOpen, onClose }: CallsProps) => {
  const calls = useCalls();

  return (
    <>
      {calls.map((call) => (
        <StreamCall call={call} key={call.cid}>
          <div
            className={clsx(
              'z-[9999] fixed left-0 top-0 h-full min-h-screen w-full items-center justify-center bg-[#0009]',
              isModalOpen ? 'flex' : 'hidden'
            )}
          >
            <div className="relative bg-[#212121] inline-flex flex-col w-full min-w-[17.5rem] my-8 mx-auto max-w-[26.25rem] max-h-[min(40rem,_100vh)] min-h-[min(80vh,_40rem)] shadow-[0_.25rem_.5rem_.125rem_var(--color-default-shadow)] rounded-2xl">
              <StreamTheme className="tg-call flex grow w-full min-h-full overflow-y-auto max-h-[92vh] custom-scroll">
                <div className="max-w-[26.25rem] w-full">
                  <div className="flex flex-col h-full overflow-y-scroll overflow-x-hidden custom-scroll pl-3.5 py-[64px]">
                    <CallModalUI onClose={onClose} />
                  </div>
                </div>
              </StreamTheme>
            </div>
          </div>
        </StreamCall>
      ))}
    </>
  );
};

export default Calls;

In the code above:

  • Active Calls Listener: The useCalls hook fetches any active calls, which allows us to render a modal for each ongoing call.

  • Wrapping Each Call: We wrap each call instance with the StreamCall component. This ensures the SDK properly manages the call’s context.

  • Conditional Modal Display: The modal container uses clsx to conditionally apply styling. It appears (using flex display) only when isModalOpen is true; otherwise, it stays hidden.

  • UI Styling and Theme: The StreamTheme component applies a consistent theme to the call UI, while internal scroll areas ensure that content remains accessible even if there are many participants.

  • Rendering the Call Modal UI: Inside the modal container, we render the CallModalUI component and pass down the onClose callback. This ties the modal’s close behavior back to our main chat interface.

Now that we've built the call components, we need to connect them to the chat interface.

Navigate to a/[channelId]/page.tsx and update it with the following code:

...
import { 
  ...
  ChannelMemberResponse, 
} from 'stream-chat';
...
import {
  Call,
  CallingState,
  MemberRequest,
  useCalls,
  useStreamVideoClient,
} from '@stream-io/video-react-sdk';
...
import Calls from '@/components/Calls';
...

const Chat = () => {
  ...
  const videoClient = useStreamVideoClient();
  const [activeCall] = useCalls();
  const [channelCall, setChannelCall] = useState<Call>();
  const [isModalOpen, setIsModalOpen] = useState(false);

  const disableCreateCall = !channelCall;
  const callActive = activeCall?.cid === channelCall?.cid;

  useEffect(() => {
    const loadChannel = async () => {
      const chatChannel = chatClient.channel('messaging', channelId);
      await chatChannel.watch();

      const channelCall = videoClient?.call('default', channelId);
      setChannelCall(channelCall);
      setChatChannel(chatChannel);
      setLoading(false);
    };

    if (chatClient && !chatChannel) loadChannel();
  }, [channelId, chatChannel, chatClient, videoClient]);

  const isDMChannel = useMemo(
    () => chatChannel?.id?.startsWith('!members'),
    [chatChannel?.id]
  );
  ...

  const getSubText = useCallback(() => {
    ...
  }, [chatChannel, user, isDMChannel, getDMUser]);

  useEffect(() => {
    if (activeCall?.state.callingState === CallingState.RINGING) {
      setIsModalOpen(true);
    }
  }, [activeCall]);

  const initiateCall = useCallback(async () => {
    if (channelCall && channelCall.state.callingState === CallingState.JOINED) {
      await channelCall?.leave();
    }

    const initialMembers = chatChannel?.state.members as Record<
      string,
      ChannelMemberResponse<DefaultStreamChatGenerics>
    >;
    const members = Object.values(initialMembers).map<MemberRequest>(
      (member) => ({
        user_id: member.user?.id as string,
        name: member.user?.name as string,
        role: isDMChannel ? 'admin' : undefined,
      })
    );

    await channelCall?.getOrCreate({
      ring: true,
      data: {
        custom: {
          channelCid: chatChannel?.cid,
          channelName: getChatName(),
          isDMChannel,
          members,
        },
        members,
      },
    });

    await channelCall?.update({
      custom: {
        triggeredBy: user?.id,
        members,
      },
    });

    if (!isDMChannel) {
      channelCall?.join();
    }
    setChannelCall(channelCall);
    setIsModalOpen(true);
  }, [
    channelCall,
    chatChannel?.cid,
    chatChannel?.state.members,
    getChatName,
    isDMChannel,
    user,
  ]);

  const onCloseModal = async () => {
    setIsModalOpen(false);
  };

  ...

  return (
    <>
      <div className="flex items-center px-2 w-full bg-background relative z-10 py-1 md:pl-[23px] md:pr-[13px] shrink-0 h-[3.5rem]">
        {/* Chat Info */}
        ...
        {/* Actions */}
        <div className="flex gap-1">
          ...
          <RippleButton
            icon="phone"
            onClick={initiateCall}
            disabled={disableCreateCall}
          />
          ...
        </div>
        {/* Active Call */}
        {callActive && (
          <div className="absolute top-[3.5rem] left-0 w-full z-[11] before:content-[''] before:absolute before:-top-0.5 before:h-0.5 before:left-0 before:right-0 before:z-[-100] before:shadow-[0_2px_2px_var(--color-light-shadow)]">
            <div className="absolute border-t border-t-color-borders top-0 z-[-1] w-full h-[2.875rem] flex justify-between items-center cursor-pointer px-3 py-[.375rem] bg-background">
              <div className="flex flex-col leading-4">
                <span className="text-[.875rem] text-black">Ongoing Call</span>
              </div>
              <button
                onClick={() => setIsModalOpen(true)}
                className="px-4 h-[1.875rem] rounded-[1rem] border border-primary bg-primary text-white uppercase text-base font-medium"
              >
                Join
              </button>
            </div>
          </div>
        )}
      </div>
      <div id="channel" className="relative w-full h-full overflow-hidden">
        <div className="flex flex-col grow items-center w-full h-full">
          <Channel
            ...
          >
            <Window>
              ...
            </Window>
            <Calls isModalOpen={isModalOpen} onClose={onCloseModal} />
          </Channel>
        </div>
      </div>
    </>
  );
};

export default Chat;

In the code above:

  • Imports and Setup:

    • We start by importing the necessary modules from both Stream Chat and Stream Video SDK. This ensures we can access types like ChannelMemberResponse and hooks like useCalls and useStreamVideoClient.

    • The Calls component is imported to render the call UI inside our chat window.

  • State Initialization:

    • We define state variables for managing the chat channel (chatChannel), loading state (loading), the call instance for the channel (channelCall), and whether the call modal is visible (isModalOpen).

    • We also get our videoClient (for handling calls) and the active call using useCalls.

  • Loading the Channel and Setting Up the Call:

    • Inside the useEffect where we load our channel, we also create a call instance specific to the channel by calling videoClient?.call('default', channelId). This binds our call functionality to the current chat.

    • Once the channel and call are ready, we update our state and set loading to false.

  • Listening for Incoming Calls:

    • Another useEffect listens for changes in the active call. If the call's state changes to RINGING, we open the call modal automatically.
  • Initiating a Call: We use the initiateCall function to create or join a call.

  • Rendering the Chat UI:

    • The chat header displays action buttons, including a phone icon that triggers initiateCall.

    • If a call is active, an "Ongoing Call" banner appears with a join button so users can easily re-open the call interface.

    • The Calls component is rendered within the chat channel, receiving isModalOpen and onCloseModal as props.

We should now have a call interface that can handle pending calls!

Adding the Joined Call Interface

So far, we've handled the UI for when a call is ringing or waiting to be answered. However, once a user actually joins the call, we need a proper interface to display the participants, their statuses, and call controls.

To do this, we’ll update CallModalUI.tsx by adding new imports and logic to support an active call session.

Inside components/CallModalUI.tsx, update the file with the following code:

...
import {
  ...
  hasScreenShare,
  isPinned,
  CancelCallButton,
  PaginatedGridLayout,
  ScreenShareButton,
  SpeakerLayout,
  SpeakingWhileMutedNotification,
  ToggleAudioPublishingButton,
  ToggleVideoPublishingButton,
} from '@stream-io/video-react-sdk';
import clsx from 'clsx';

import Avatar from './Avatar';
...

const CallModalUI = ({ onClose }: CallModalUIProps) => {
  ...
  const {
    ...
    useParticipants,
    useParticipantCount,
  } = useCallStateHooks();
  ...
  const participants = useParticipants();
  const [participantInSpotlight] = participants;

  const participantCount = useParticipantCount();
  const isSpeakerLayout = useMemo(() => {
    if (participantInSpotlight) {
      return (
        hasScreenShare(participantInSpotlight) ||
        isPinned(participantInSpotlight)
      );
    }
    return false;
  }, [participantInSpotlight]);

  ...

  if (
    [CallingState.RINGING, CallingState.JOINING, CallingState.IDLE].includes(
      callingState
    )
  ) {
    return (
      ...
    );
  } else if (callingState === CallingState.JOINED) {
    return (
      <>
        <div className="top-0 absolute pt-1.5 w-[calc(100%-24px)] z-20 h-[64px] bg-[#212121] flex items-center pb-3.5 select-none text-white">
          <div className="[&>button]:text-white [&>button]:w-[2.75rem] [&>button]:h-[2.75rem]">
            <RippleButton icon="fullscreen" />
          </div>
          <div className="flex flex-col justify-center overflow-hidden ml-[1.375rem]">
            <h3 className="text-[1rem] font-medium truncate whitespace-pre leading-[1.375rem]">
              {chatName}
            </h3>
            {!customData.isDMChannel && (
              <span className="inline-block truncate text-color-text-secondary text-sm leading-[1.125rem]">
                {participantCount} participant{participantCount > 1 ? 's' : ''}
              </span>
            )}
          </div>
          <div
            onClick={onClose}
            className="ml-auto [&>button]:text-white [&>button]:w-[2.75rem] [&>button]:h-[2.75rem]"
          >
            <RippleButton icon="close" />
          </div>
        </div>
        <div className="mx-2 my-[.125rem] me-[calc(.5rem-11px)]">
          <div className="w-full relative">
            {!isSpeakerLayout && <PaginatedGridLayout groupSize={4} />}
            {isSpeakerLayout && <SpeakerLayout participantsBarLimit={3} />}
          </div>
          <div className="w-full mt-2 pb-[6rem]">
            {participants.map((participant) => (
              <div key={participant.sessionId} className="relative w-full">
                <button className="w-full p-2 min-h-[3rem] flex items-center relative overflow-hidden whitespace-nowrap text-color-text rounded-xl hover:bg-[#ffffff0a]">
                  <div className="mr-2">
                    <Avatar
                      data={{
                        name: participant.name,
                        image: participant.image,
                      }}
                      width={54}
                    />
                  </div>
                  <div className="grow whitespace-[initial] overflow-hidden pt-[.4375rem] pb-[.5625rem]">
                    <div className="flex items-center truncate leading-[1.25rem] gap-2">
                      <h3 className="text-[1rem] font-medium text-white">
                        {participant.name}
                      </h3>
                    </div>
                    <span
                      className={clsx(
                        'flex leading-4 mt-1 text-start',
                        participant.isSpeaking
                          ? 'text-[#57bc6c]'
                          : 'text-primary'
                      )}
                    >
                      <span className="truncate">
                        {participant.isSpeaking
                          ? 'speaking'
                          : participant.userId === user?.id
                          ? 'this is you'
                          : 'listening'}
                      </span>
                    </span>
                  </div>
                  <div
                    className={clsx(
                      'w-7 h-7 ml-auto mr-1',
                      participant.isSpeaking
                        ? 'text-[#57bc6c]'
                        : 'text-color-text-secondary'
                    )}
                  >
                    <i
                      className={clsx(
                        'icon',
                        participant.isSpeaking
                          ? 'icon-microphone'
                          : 'icon-microphone-alt'
                      )}
                    />
                  </div>
                </button>
              </div>
            ))}
          </div>
        </div>
        <div className="bottom-0 absolute pt-2 w-[calc(100%-24px)] z-20 h-[64px] bg-[#212121] self-end flex items-center justify-center mt-auto mb-4 gap-4">
          <ScreenShareButton />
          <SpeakingWhileMutedNotification>
            <ToggleAudioPublishingButton />
          </SpeakingWhileMutedNotification>
          <ToggleVideoPublishingButton />
          <CancelCallButton onClick={endCall} />
        </div>
      </>
    );
  }

  return null;
};

export default CallModalUI;

Let’s go over the new updates in this component:

  • Additional Imports and Setup:

    • We import extra components and hooks (e.g., hasScreenShare, isPinned, PaginatedGridLayout, SpeakerLayout, and various toggle buttons) from the Stream Video SDK to build a more feature-rich call interface.

    • We also import useUser from Clerk and our custom Avatar component to display participant details.

  • Managing Participants:

    • We use hooks like useParticipants and useParticipantCount to get the list and count of current call participants.

    • The code checks for a participant in the spotlight and, using a useMemo hook, determine if a speaker layout is needed (for example, when someone is sharing their screen or is pinned).

  • Conditional UI Rendering Based on Call State:

    • For call states like RINGING, JOINING, or IDLE, the previous UI (for incoming calls) is displayed.

    • When the call state is JOINED, the interface switches to a joined call view:

      • Header Section: Displays the channel name and, if applicable, the number of participants. A close button is provided to exit the call view.

      • Participant List: Uses either a grid layout or a speaker layout (depending on the isSpeakerLayout flag) to show all participants with their avatars, names, and statuses (e.g., “speaking”, “this is you”, or “listening”).

      • Bottom Controls: Offers buttons to start screen sharing, toggle audio/video, and cancel the call, ensuring users have quick access to essential call functions.

Next, let's add some important styles to our globals.css file to ensure our call UI looks polished:

...
.str-video__participant-view .str-video__participant-details {
  background-color: rgba(0, 0, 0, 0.578);
}

.str-video__participant-details__name,
.str-video__notification .str-video__notification__message {
  color: #ffffff;
}

.str-video__composite-button .str-video__composite-button__button,
.str-video__composite-button__button-group {
  height: 54px;
  display: flex;
  align-items: center;
  justify-content: center;
}

[data-testid="screen-share-start-button"] {
  width: 54px !important;
  height: 54px !important;
  padding: 0 !important;
  display: flex;
  align-items: center;
  justify-content: center;
}

[data-testid="cancel-call-button"] {
  width: 66px !important;
  height: 54px !important;
  display: flex;
  align-items: center;
  justify-content: center;
}

.str-video__composite-button__button-group.str-video__composite-button__button-group--active-primary {
  width: 54px !important;
}

.str-video__icon.str-video__icon--screen-share-off,
.str-video__icon.str-video__icon--screen-share,
.str-video__icon.str-video__icon--mic-off,
.str-video__icon.str-video__icon--mic,
.str-video__icon.str-video__icon--camera-off,
.str-video__icon.str-video__icon--camera,
.str-video__icon.str-video__icon--call-end {
  width: 24px !important;
  height: 24px !important;
}

.str-video__participant-details__connection-quality {
  display: none !important;
}

.tg-call .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;
}

.tg-call .str-video__generic-menu {
  padding: 8px 0;
  gap: 4px;
}

.tg-call .str-video__generic-menu--item {
  background: transparent;
  border-radius: 0;
  padding: 0;
  height: 28px;
}

.tg-call .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;
}

.tg-call .str-video__generic-menu .str-video__generic-menu--item button:hover {
  background: #37383b;
}

.tg-call .str-video__screen-share-overlay {
  z-index: 10;
}

.tg-call .str-video__screen-share-overlay .str-video__screen-share-overlay__title {
  font-size: 18px;
  color: white;
}
...

Adding an Indicator for Ongoing Calls

For the last feature of our app, we want to show active calls in the chat list so users can see which conversations have ongoing calls.

Modify /components/ChatPreview.tsx with the following code:

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

const ChatPreview = ({
  ...
}: ChannelPreviewUIComponentProps) => {
  ...
  const [activeCall] = useCalls();
  const callActive = activeCall?.id === channel.id;
  ...

  return (
    <div
      ...
    >
      <div className="relative">
        <Avatar
          ...
        />
        {callActive && (
          <StreamTheme>
            <div className="absolute bottom-0.5 right-0 w-4 h-4 flex items-center justify-center bg-white border-2 border-primary rounded-full">
              <span className="str-video__speech-indicator str-video__speech-indicator--speaking">
                <span className="str-video__speech-indicator__bar !w-0.5 !h-1/2" />
                <span className="str-video__speech-indicator__bar !w-0.5 !h-1/2" />
                <span className="str-video__speech-indicator__bar !w-0.5 !h-1/2" />
              </span>
            </div>
          </StreamTheme>
        )}
      </div>
      ...
  );
};

export default ChatPreview;

In the code above, we check for an active call in each chat by checking if the call’s ID tallies with the channel ID. If it does, we display an indicator (speech icon) on the chat avatar.

And with that, our Telegram clone is now complete!

Conclusion

With this final step, we’ve successfully integrated video calling into our Telegram clone using Stream React Video and Audio SDK.

Throughout this guide, we’ve covered everything from setting up authentication and adding chat functionality to adding real-time calls.

You can take things even further by exploring Stream SDKs and looking for ways to extend your app and make it your own. You could add features like threads and replies, typing indicators, and many others.

Check out the live demo and GitHub repository to dive into the full implementation.