Build a Signal Clone with React Native and Stream – Part One



This content originally appeared on DEV Community and was authored by Oluwabusayo Jacobs

Ever wondered how popular messaging apps like Signal manage features such as real-time chat, voice calls, and video chat? In this tutorial, we’ll build our own Signal-inspired messaging app using React Native, with Clerk handling authentication and Stream powering real-time chat and calls.

In this first part, we’ll focus on setting up the project and laying the foundation. Specifically, we’ll:

  • Set up a new Expo project

  • Integrate user authentication using Clerk

  • Install and configure the Stream React Native Chat SDK

  • Build out the core layout of the app

In the second part, we’ll implement a functional chat screen and add audio and video calling to our app using Stream’s React Native Video and Audio SDK.

If you’re interested in seeing how real-time chat and video can be implemented in a React Native app, this guide will walk you through everything step by step.

You can download the app to see it in action or check out the complete source code on GitHub.

Let’s get started!

Prerequisites

To get the most out of this tutorial, you should have a basic understanding of the following:

  • React Fundamentals: You should know how to utilize components, hooks, and manage state in React. You should also be familiar with React Native’s core components.

  • Node.js & npm: Make sure you have Node.js and npm installed on your machine, as they’re required to run the project.

  • TypeScript & TailwindCSS Basics: We’ll be using both throughout the tutorial. A general understanding of TypeScript syntax and Tailwind’s utility classes will help you follow along more smoothly.

Project Setup

Let’s start by setting up the project with Expo and Nativewind.

Expo provides a set of tools to help build native apps faster. It will serve as the framework for our React Native app.

Nativewind, on the other hand, will allow us to style our app using Tailwind CSS.

Creating an Expo Project

Run the following command to create your Expo project:

npx create-expo-app@latest signal-clone

This creates a new Expo project named signal-clone with a boilerplate setup.

Your project structure should look like the image below:

Project structure

Let’s clean things up a bit and remove everything we won’t need:

  • Delete the (tabs) folder and all files in the app directory.

  • Inside the hooks folder, delete all files.

  • In the components directory, delete everything except HapticTab.tsx.

  • You can also remove the entire ui folder if it exists.

Installing Nativewind

Next, set up Nativewind for your project by following the steps below:

  1. Install Nativewind and dependencies:

    npm install nativewind react-native-reanimated@~3.17.4 react-native-safe-area-context@5.4.0 clsx
    npm install -D tailwindcss@^3.4.17 prettier-plugin-tailwindcss@^0.5.11
    
  2. Set up Tailwind CSS:

    1. Run the following to create a tailwind.config.js file:

      npx tailwindcss init
      
    2. Edit your tailwind.config.js file with the following code:

      /** @type {import('tailwindcss').Config} */
      module.exports = {
        content: ['./app/**/*.{js,jsx,ts,tsx}', './components/**/*.{js,ts,tsx}'],
        presets: [require('nativewind/preset')],
        theme: {
          extend: {
            colors: {
              primary: '#2c6bed',
            },
          },
        },
        plugins: [],
      };
      
    3. Create a global.css file in the root directory and add the following code:

      @tailwind base;
      @tailwind components;
      @tailwind utilities;
      
  3. Add the Babel preset:

    Create a babel.config.js file and add the following snippet:

    module.exports = function (api) {
      api.cache(true);
      return {
        presets: [
          ["babel-preset-expo", { jsxImportSource: "nativewind" }],
          "nativewind/babel",
        ],
      };
    };
    
  4. Create your Metro configuration:

    Create a metro.config.js file in the root of your project with the following code:

    const { getDefaultConfig } = require('expo/metro-config');
    const { withNativeWind } = require('nativewind/metro');
    
    const config = getDefaultConfig(__dirname);
    
    module.exports = withNativeWind(config, {
      input: './global.css',
      inlineRem: 16,
    });
    
  5. Add Nativewind type definitions:

    Create a nativewind-env.d.ts file in your project directory and add the following directive:

    /// <reference types="nativewind/types" />
    

Testing the Setup

Let’s test our setup by creating our first screen and running the development server to see the results.

Create a _layout.tsx file in the app folder with the following code:

import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { GestureHandlerRootView } from 'react-native-gesture-handler';

import '../global.css';

const RootLayout = () => {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <Stack>
        <Stack.Screen name="index" options={{ headerShown: false }} />
      </Stack>
      <StatusBar style="auto" />
    </GestureHandlerRootView>
  );
};

export default RootLayout;

This creates our root layout with basic gesture support and a stack navigation.

Next, create an index.tsx file in the app directory and add the following code:

import { Text, View } from 'react-native';

const WelcomeScreen = () => {
  return (
    <View className="flex-1 items-center justify-center bg-white">
      <Text className="text-lg font-semibold text-primary">
        Welcome to Signal!
      </Text>
    </View>
  );
};

export default WelcomeScreen;

Lastly, run the development server with the command below:

npx expo start

Once you run the command above, the server will start, and you’ll see a QR code in the terminal.

Terminal preview

Next, install the Expo Go app on your iOS or Android device. This app lets you preview and test your project directly on your phone. You can also set up Expo Go on an Android or iOS emulator.

Once you install the app, scan the QR code to open it on your device. For Android, select the Expo Go > Scan QR code option. For iOS, use the default camera app.

If you follow the above steps, you should be able to launch the welcome screen successfully!

Welcome screen

Download assets

To finish up your project setup, you’ll need to add the assets from the archive below:

📁
Download assets archive

After downloading the archive, unzip it and replace the default assets in the signal-clone/assets/images directory.

Your assets/images directory should look like the image below:

Images directory

Building the Welcome Screen

So far, our welcome screen only returns a simple welcome message. Let’s improve it by adding a few key components:

Screen component

The screen component will serve as the container for our screens.

Create a Screen.tsx file in the components directory and add the following snippet:

import clsx from 'clsx';
import { ActivityIndicator, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';

interface ScreenProps {
  children: React.ReactNode;
  className?: string;
  viewClassName?: string;
  loadingOverlay?: boolean;
}

const Screen = ({
  children,
  className,
  viewClassName,
  loadingOverlay = false,
}: ScreenProps) => {
  return (
    <>
      <SafeAreaView className={clsx('flex-1 android:pt-3', className)}>
        <View className={clsx('flex-1', viewClassName)}>{children}</View>
      </SafeAreaView>
      {loadingOverlay && (
        <View className="absolute inset-0 bg-black/40 items-center justify-center z-50">
          <ActivityIndicator color="white" />
        </View>
      )}
    </>
  );
};

export default Screen;

The Screen component wraps our content in a SafeAreaView to keep it within safe screen boundaries. It also accepts optional class names for styling and an optional loadingOverlay prop to show a loading screen.

AppImage component

The AppImage will be a simple image component that can accept Tailwind styles. This component will display a splash image on the welcome screen.

Create an AppImage.tsx file in the components directory with the following code:

import { Image, ImageProps } from 'expo-image';
import { cssInterop } from 'nativewind';

cssInterop(Image, {
  className: {
    target: 'style',
  },
});

const AppImage = (props: ImageProps) => {
  return <Image {...props} />;
};

export default AppImage;

Here, we wrap and return Expo’s Image component with Nativewind’s cssInterop so it can accept Tailwind className styles.

Button component

The button will enable users to perform actions within our UI. We’ll also use it in our welcome screen to display a call-to-action that takes the user to the next step.

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

import clsx from 'clsx';
import { Text, TouchableOpacity } from 'react-native';

interface ButtonProps extends React.ComponentProps<typeof TouchableOpacity> {
  onPress?: () => void;
  children: React.ReactNode;
  className?: string;
  variant?: 'plain' | 'default' | 'text';
}

const Button = ({
  onPress,
  children,
  className,
  variant = 'default',
  ...otherProps
}: ButtonProps) => {
  if (variant === 'plain')
    return (
      <TouchableOpacity
        className={clsx('w-fit h-fit', className)}
        onPress={onPress}
        {...otherProps}
      >
        {children}
      </TouchableOpacity>
    );

  return (
    <TouchableOpacity
      className={clsx(
        variant === 'default' &&
          'bg-blue-600 rounded-[13px] justify-center items-center px-4 py-4 w-full',
        variant === 'text' && 'bg-transparent justify-center items-center',
        otherProps.disabled && 'opacity-50',
        variant !== 'text' && className
      )}
      onPress={onPress}
      {...otherProps}
    >
      <Text
        className={clsx(
          variant === 'default' && 'text-[17px] font-medium text-white',
          variant === 'text' && 'text-sm text-black',
          variant === 'text' && className
        )}
      >
        {children}
      </Text>
    </TouchableOpacity>
  );
};

export default Button;

In the code above, the Button component accepts three variants (default, plain, and text) for different styling options, and uses TouchableOpacity to handle user interactions with visual feedback.

Building the Welcome Screen

With our components in place, let’s build out our complete welcome screen.

Head to the index.tsx file and update it with the following code:

import { useRouter } from 'expo-router';
import { Text, View } from 'react-native';

import AppImage from '@/components/AppImage';
import Button from '@/components/Button';
import Screen from '@/components/Screen';

const WelcomeScreen = () => {
  const router = useRouter();

  return (
    <Screen
      className="bg-white"
      viewClassName="px-10 pb-10 w-full items-center justify-end gap-16"
    >
      <AppImage
        source={require('@/assets/images/onboarding_splash_Normal.png')}
        className="w-[85%] h-[55%]"
        contentFit="cover"
      />
      <View className="flex items-center gap-4">
        <View className="flex w-full items-center">
          <Text className="text-center text-[28.5px] font-semibold">
            Take privacy with you.
          </Text>
          <Text className="w-[210px] text-center text-[28.5px] font-semibold">
            Be yourself in every message.
          </Text>
        </View>
        <Text className="text-base text-gray-500">Terms & Privacy Policy</Text>
      </View>
      <Button onPress={() => router.navigate('/sign-up')}>Continue</Button>
    </Screen>
  );
};

export default WelcomeScreen;

This new WelcomeScreen displays a splash image, a few short texts, and a call-to-action button redirecting the user to a new screen.

Complete welcome screen

Adding User Authentication with Clerk

If the user clicks the “Continue” button on our welcome screen right now, they’d be taken to a sign-up screen that doesn’t exist yet. Before working on this screen, we must implement user authentication within our app. This is where Clerk comes in.

What is Clerk?

Clerk is a user management platform. It provides tools that enable developers to easily implement authentication systems and user profiles without building them from scratch.

We’ll use Clerk in our Signal clone to handle authentication, user profiles, and sessions, so we can focus on building the actual chat experience.

Setting Up a Clerk Account

Clerk sign up page

To get started with Clerk, first create an account on their website. Go to Clerk’s sign-up page and sign up using your email or a social login option.

Creating a Clerk Project

Clerk create application page

Once you sign in, you’ll need to create a project for your app. You can do this by following the steps below:

  1. Go to the dashboard and click “Create application“.

  2. Name your application “Signal clone”.

  3. Under “Sign in options,” ensure Email is selected.

  4. Click “Create application” to complete the setup.

Clerk app overview

Once the project is created, you’ll be redirected to the app overview page. Here, you will find your Publishable Key. Save this key, as it will be helpful later.

Clerk configure page

Next, we need to ensure that users provide a first and last name, and a username, during the sign-up process. Follow the steps below to enable this feature:

  1. Go to your dashboard’s “Configure” tab.

  2. Find the “Username” option and enable it, then disable the “Allow user name for sign-in” option.

  3. Locate the “First and last name” option and toggle it on.

  4. Click “Continue” to save the changes.

Installing Clerk in Your Project

Next, let’s install Clerk into your Expo project:

  1. Install Clerk’s Expo SDK by running the following command:

    npm install @clerk/clerk-expo
    
  2. Create a .env.local file in the root of your project and add your Clerk Publishable Key:

    EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=your_clerk_publishable_key
    

    Replace your_clerk_publishable_key with the key from your Clerk project dashboard.

  3. To get the user and authentication data throughout our app, we must wrap our main layout with the Clerk’s <ClerkProvider /> component.

    Navigate to the app/_layout.tsx file and update it with the following code:

    import { ClerkProvider } from '@clerk/clerk-expo';
    ...
    
    const PUBLISHABLE_KEY = process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
    
    const RootLayout = () => {
      return (
        <GestureHandlerRootView style={{ flex: 1 }}>
          <ClerkProvider publishableKey={PUBLISHABLE_KEY}>
            <Stack>
              <Stack.Screen name="index" options={{ headerShown: false }} />
              <Stack.Screen name="(auth)" options={{ headerShown: false }} />
            </Stack>
            <StatusBar style="auto" />
          </ClerkProvider>
        </GestureHandlerRootView>
      );
    };
    
    export default RootLayout;
    

    We also add an (auth) stack to the layout, which we’ll work on later.

  4. Clerk stores the active user’s session token in memory by default. In Expo apps, the recommended way to store sensitive data, such as tokens, is by using expo-secure-store, which encrypts the data before storing it. To use a token cache:

    1. Run the following command to install the libraries:

      npm install expo-secure-store
      
    2. Update the root layout to use the secure token cache:

      ...
      import { tokenCache } from '@clerk/clerk-expo/token-cache';
      ...
      
      const RootLayout = () => {
        return (
          ...
            <ClerkProvider tokenCache={tokenCache} publishableKey={PUBLISHABLE_KEY}>
              ...
            </ClerkProvider>
          ...
        );
      };
      
      export default RootLayout;
      
  5. Let’s automatically redirect users to a /chats route if they’re already signed in.

    Open app/index.tsx and update it like this:

    import { useUser } from '@clerk/clerk-expo';
    import { Redirect, useRouter } from 'expo-router';
    ...
    
    const WelcomeScreen = () => {
      const { isLoaded, isSignedIn } = useUser();
      const router = useRouter();
    
      if (!isLoaded) return null;
    
      if (isSignedIn) {
        return <Redirect href="/chats" />;
      }
    
      return (
        ...
      );
    };
    
    export default WelcomeScreen;
    
  6. Create an (auth) folder in the app directory. This folder represents the route group for our sign-up and sign-in screens. In the (auth) group, create a _layout.tsx file with the following code:

    import { useAuth } from '@clerk/clerk-expo';
    import { Redirect, Stack } from 'expo-router';
    
    const AuthLayout = () => {
      const { isSignedIn } = useAuth();
    
      if (isSignedIn) {
        return <Redirect href={'/chats'} />;
      }
    
      return (
        <Stack screenOptions={{ headerShown: false }}>
          <Stack.Screen name="sign-in" />
          <Stack.Screen name="sign-up" />
        </Stack>
      );
    };
    
    export default AuthLayout;
    

Creating the Sign-Up and Sign-In Screens

With Clerk installed, let’s create our sign-up and sign-in screens.

These screens will use three new modules to handle the user form logic:

  • TextField: This component will serve as the primary text input for our application.

  • useUserForm: This custom hook will manage and update the state for our user forms.

  • utils: This module will provide utility functions throughout our app.

Let’s start with the TextField component. Create a TextField.tsx file in the components directory and add the following code:

import clsx from 'clsx';
import { DimensionValue, Text, TextInput, View } from 'react-native';

interface TextFieldProps extends React.ComponentProps<typeof TextInput> {
  width?: DimensionValue;
  label?: string;
}

const TextField = ({
  label,
  width = '100%',
  className,
  ...otherProps
}: TextFieldProps) => {
  return (
    <View
      style={{ width }}
      className="relative px-4 flex-row bg-white items-center justify-between rounded-xl py-3 android:py-0 border border-white"
    >
      {label && (
        <View>
          <Text className="w-[108px] font-medium">{label}</Text>
        </View>
      )}
      <TextInput
        className={clsx(
          'flex-1 placeholder:text-gray-400 text-black',
          className
        )}
        {...otherProps}
      />
    </View>
  );
};

export default TextField;

Next, let’s create our custom hook. In the hooks folder, create a useUserForm.tsx file with the following code:

import { useState } from 'react';

interface InitialValues {
  firstName?: string;
  lastName?: string;
  username?: string;
  usernameNumber?: string;
  numberError?: string;
  emailAddress?: string;
  password?: string;
}

const defaultValues: InitialValues = {
  firstName: '',
  lastName: '',
  username: '',
  usernameNumber: '',
  numberError: '',
  emailAddress: '',
  password: '',
};

const useUserForm = (initialValues = defaultValues) => {
  const [firstName, setFirstName] = useState(initialValues.firstName || '');
  const [lastName, setLastName] = useState(initialValues.lastName || '');
  const [username, setUsername] = useState(initialValues.username || '');
  const [usernameNumber, setUsernameNumber] = useState(
    initialValues.usernameNumber || ''
  );
  const [numberError, setNumberError] = useState(
    initialValues.numberError || ''
  );
  const [emailAddress, setEmailAddress] = useState(
    initialValues.emailAddress || ''
  );
  const [password, setPassword] = useState(initialValues.password || '');

  const onChangeUsername = (text: string) => {
    setUsername(text);
    if (!usernameNumber) {
      const randomNumber = String(Math.floor(Math.random() * 99) + 1).padStart(
        2,
        '0'
      );
      setUsernameNumber(randomNumber);
      setNumberError('');
    }
  };

  const onChangeNumber = (number: string) => {
    if (number === '00') return;
    setUsernameNumber(number);

    const isNumber = /^\d+$/.test(number);
    const isValid = isNumber && number.length === 2;

    if (!isValid) {
      setNumberError('Invalid username, enter a minimum of 2 digits');
    } else {
      setNumberError('');
    }
  };

  const onChangeFirstName = (text: string) => {
    setFirstName(text);
  };

  const onChangeLastName = (text: string) => {
    setLastName(text);
  };

  const onChangeEmailAddress = (text: string) => {
    setEmailAddress(text);
  };

  const onChangePassword = (text: string) => {
    setPassword(text);
  };

  return {
    firstName,
    lastName,
    username,
    usernameNumber,
    numberError,
    emailAddress,
    password,
    onChangeNumber,
    onChangeUsername,
    onChangeFirstName,
    onChangeLastName,
    onChangeEmailAddress,
    onChangePassword,
  };
};

export default useUserForm;

The useUserForm hook accepts the initialValues prop and uses it to initialize the following states:

  • firstName and lastName: These states make up the full name of the user. They are updated via the onChangeFirstName and onChangeLastName handlers respectively.

  • emailAddress and password: These states represent the auth credentials for the user. They are updated through the onChangeEmail and onChangePassword functions.

  • username, usernameNumber, and numberError: These states help derive the full username of the user. In Signal, usernames are paired with a two-digit number as a privacy safeguard. This number is represented by the usernameNumber. The username and usernameNumber states also come with the following change handlers:

    • onChangeUsername: Updates the username and generates a random usernameNumber if it’s not already set.
    • onChangeNumber: Updates the usernameNumber and runs a simple validation. It also sets the numberError to display a helpful message if the validation fails.

The userUserForm hook then returns all current states and the change handlers for each field.

Next, let’s create our utility module. Create a lib folder, and then add a utils.ts file with the following code:

export const getError = (err: any) => {
  const errors = err.errors as { longMessage: string }[];
  const errorMessage = errors
    .map((error, index) => `${index + 1}. ${error.longMessage}`)
    .join('\n');
  alert(errorMessage);
};

This function accepts an error object from a Clerk API request and displays the error messages using an alert.

With our modules set up, let’s create our screens.

Starting with the sign-up screen, create a sign-up.tsx file in the (auth) folder and add the following code:

import { useSignUp } from '@clerk/clerk-expo';
import clsx from 'clsx';
import { Link, useRouter } from 'expo-router';
import { useState } from 'react';
import { Text, TextInput, View } from 'react-native';

import Button from '@/components/Button';
import Screen from '@/components/Screen';
import TextField from '@/components/TextField';
import useUserForm from '@/hooks/useUserForm';
import { getError } from '@/lib/utils';

const SignUpScreen = () => {
  const { isLoaded, signUp, setActive } = useSignUp();
  const router = useRouter();
  const {
    firstName,
    lastName,
    username,
    usernameNumber,
    numberError,
    emailAddress,
    password,
    onChangeFirstName,
    onChangeLastName,
    onChangeUsername,
    onChangeNumber,
    onChangeEmailAddress,
    onChangePassword,
  } = useUserForm();
  const [pendingVerification, setPendingVerification] = useState(false);
  const [code, setCode] = useState('');
  const [loading, setLoading] = useState(false);

  const onSignUpPress = async () => {
    if (!isLoaded || numberError) return;
    setLoading(true);
    try {
      const finalUsername = `${username}_${usernameNumber}`;
      await signUp.create({
        firstName,
        lastName,
        username: finalUsername,
        emailAddress: emailAddress.toLowerCase(),
        password,
      });
      await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
      setPendingVerification(true);
    } catch (err) {
      getError(err);
    } finally {
      setLoading(false);
    }
  };

  const onVerifyPress = async () => {
    if (!isLoaded) return;
    setLoading(true);
    try {
      const signUpAttempt = await signUp.attemptEmailAddressVerification({
        code,
      });

      if (signUpAttempt.status === 'complete') {
        await setActive({ session: signUpAttempt.createdSessionId });
        router.replace('/chats');
      } else {
        console.error(JSON.stringify(signUpAttempt, null, 2));
      }
    } catch (err) {
      getError(err);
    } finally {
      setLoading(false);
    }
  };

  if (pendingVerification) {
    return (
      <Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
        <View className="gap-3">
          <Text className="text-center text-3xl font-semibold">
            Verify email address
          </Text>
          <Text className="text-center text-base text-gray-500">
            Enter the code we sent to {emailAddress.toLowerCase()}
          </Text>
          <Button
            variant="text"
            className="text-base text-blue-600"
            onPress={() => setPendingVerification(false)}
          >
            Wrong email?
          </Button>
        </View>
        <TextField
          value={code}
          placeholder="Enter your verification code"
          keyboardType="numeric"
          onChangeText={(code) => setCode(code)}
        />
        <Button onPress={onVerifyPress}>Verify</Button>
      </Screen>
    );
  }

  return (
    <Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
      <View className="gap-3">
        <Text className="text-center text-3xl font-semibold">Sign up</Text>
        <Text className="text-center text-base text-gray-500">
          Create an account to get started
        </Text>
      </View>
      <View className="gap-3">
        <TextField
          value={firstName}
          placeholder="First name"
          onChangeText={onChangeFirstName}
        />
        <TextField
          value={lastName}
          placeholder="Last name"
          onChangeText={onChangeLastName}
        />
        <View className="relative">
          <TextField
            autoCapitalize="none"
            value={username}
            placeholder="Username"
            onChangeText={onChangeUsername}
            className="pr-12"
          />
          <View className="absolute right-3 top-3 flex-row gap-2">
            <View className="w-0.5 h-5 bg-gray-300" />
            <TextInput
              keyboardType="number-pad"
              maxLength={2}
              value={usernameNumber}
              onChangeText={onChangeNumber}
              className="w-5 h-5 android:w-8 android:h-12 android:bottom-3.5"
            />
          </View>
          <Text
            className={clsx(
              'pl-2 pt-2 text-xs',
              numberError ? 'text-red-500' : 'text-gray-500'
            )}
          >
            {numberError ||
              'Usernames are always paired with a set of numbers.'}
          </Text>
        </View>
        <TextField
          autoCapitalize="none"
          value={emailAddress}
          placeholder="Email address"
          onChangeText={onChangeEmailAddress}
        />
        <TextField
          value={password}
          placeholder="Password"
          secureTextEntry={true}
          onChangeText={onChangePassword}
        />
      </View>
      <Button onPress={onSignUpPress}>Continue</Button>
      <View className="flex-row gap-[3px]">
        <Text>Already have an account?</Text>
        <Link href="/sign-in">
          <Text className="text-blue-600">Sign in</Text>
        </Link>
      </View>
    </Screen>
  );
};

export default SignUpScreen;

The sign-up screen renders two forms:

  • Initial Form: This is the form provided to the user to fill out their details. In this form:

    • We use the TextField component to render the form fields for the user registration.
    • We also render a Text element below the username field that displays either a helper message or the numberError when the usernameNumber is invalid.
    • The form state is managed using the useUserForm hook.
    • When the user clicks “Continue”, the onSignupPress function is called. This function creates a finalUsername by combining the username and usernameNumber with an underscore. Then, using Clerk’s signUp object from the useSignUp hook, it registers the user with signUp.create() and sends a verification email using signUp.prepareEmailAddressVerification().
  • Verification Form: After the sign-up is successful, the user is redirected to the verification form and prompted to enter the email verification code. Once the user clicks “Verify”, the onVerifyPress function is called. This function uses the signUp.attemptEmailAddressVerification() method to verify the user’s email based on the code provided. If successful, it runs the setActive() function (from the useSignUp hook) to create an active session and then redirects the user to /chats.

Sign up screen

Next, create a sign-in.tsx file in the (auth) folder, then add the following code:

import { useSignIn } from '@clerk/clerk-expo';
import { Link, useRouter } from 'expo-router';
import { useState } from 'react';
import { Text, View } from 'react-native';

import Button from '@/components/Button';
import Screen from '@/components/Screen';
import TextField from '@/components/TextField';
import useUserForm from '@/hooks/useUserForm';
import { getError } from '@/lib/utils';

const SignInScreen = () => {
  const { signIn, setActive, isLoaded } = useSignIn();
  const router = useRouter();

  const { emailAddress, password, onChangeEmailAddress, onChangePassword } =
    useUserForm();
  const [loading, setLoading] = useState(false);

  const onSignInPress = async () => {
    if (!isLoaded) return;
    setLoading(true);
    try {
      const signInAttempt = await signIn.create({
        identifier: emailAddress,
        password,
      });

      if (signInAttempt.status === 'complete') {
        await setActive({ session: signInAttempt.createdSessionId });
        router.replace('/chats');
      } else {
        alert('State incomplete');
      }
    } catch (err) {
      getError(err);
    } finally {
      setLoading(false);
    }
  };

  return (
    <Screen viewClassName="pt-10 px-4 gap-4" loadingOverlay={loading}>
      <View className="gap-3">
        <Text className="text-center text-3xl font-semibold">Sign in</Text>
        <Text className="text-center text-base text-gray-500">
          Enter your email address and password to sign in.
        </Text>
      </View>
      <TextField
        autoCapitalize="none"
        value={emailAddress}
        placeholder="Enter email"
        onChangeText={onChangeEmailAddress}
      />
      <TextField
        value={password}
        placeholder="Enter password"
        secureTextEntry={true}
        onChangeText={onChangePassword}
      />
      <Button onPress={onSignInPress}>Continue</Button>
      <View className="flex-row gap-[3px]">
        <Text>Don&apos;t have an account?</Text>
        <Link href="/sign-up">
          <Text className="text-blue-600">Sign up</Text>
        </Link>
      </View>
    </Screen>
  );
};

export default SignInScreen;

In this code, we:

  • Render two TextField components for the user’s email address and password.

  • Use the useUserForm hook to manage the form field states.

  • Run the onSignInPress function when the user clicks “Continue”. This function utilizes the useSignIn hook to:

    • Attempt a sign-in using the signIn.create() method.
    • If the sign-in process was successful, use the setActive() function to set the created session as active and then redirect the user.

Sign in screen

Now that we have Clerk and our auth screens set up, let’s move on to setting up Stream Chat.

Setting Up Stream Chat

What is Stream?

Stream is a platform that provides APIs and SDKs to help build real-time messaging, video calling, and activity feeds. We’ll use Stream’s React Native SDK for Video and React Native Chat SDK to implement video calling and chat messaging features in our Signal clone.

Creating your Stream Account

Stream sign up page

The first step to using Stream is to create a Stream account:

  1. Sign Up: Go to Stream’s sign-up page and create a new account using your email or a social login.

  2. Complete Your Profile:

* Once you sign up, you'll be prompted to provide additional information, such as your role and industry.

* Select the **"Chat Messaging"** and **"Video and Audio"** options, as our app needs these features.

    ![Stream sign up options](https://cdn.hashnode.com/res/hashnode/image/upload/v1726664432078/966254af-e0b3-4a54-b395-52667e6374b7.png?align=%22left%22 align="left")

* Click **"Complete Signup"**.

After completing the steps above, you will be redirected to your Stream dashboard.

Creating a New Stream Project

Stream app modal

Next, follow the steps below to create a new app for your project:

  1. Click “Create App” in the top right corner of your Stream dashboard.

  2. Configure Your App:

* **App Name**: Enter a name like "**signal-clone**" or any other name available.

* **Region**: Select the closest region to you to ensure you get the best performance.

* **Environment**: Set it to "**Development**".

* Click "**Create App**" to complete the process.

Installing Stream Chat

To begin using Stream Chat in your Expo project, follow these steps:

  1. Run the following command to install the necessary packages for Stream Chat:

    npx expo install stream-chat stream-chat-expo @react-native-community/netinfo expo-image-manipulator react-native-gesture-handler react-native-reanimated react-native-svg expo-video expo-av expo-sharing expo-haptics expo-clipboard expo-document-picker expo-image-picker expo-media-library @op-engineering/op-sqlite
    
  2. Add your Stream API keys to your .env.local file:

    EXPO_PUBLIC_STREAM_API_KEY=your_stream_api_key
    STREAM_API_SECRET=your_stream_api_secret
    

    Replace your_stream_api_key and your_stream_api_secret with the keys from the “App Access Keys” section in your Stream dashboard.

  3. Configure the app permissions in your project’s app.json file:

    {
      "expo": {
        ...
        "plugins": [
          ...
          [
            "expo-av",
            {
              "microphonePermission": "$(PRODUCT_NAME) would like to use your microphone for voice recording."
            }
          ],
          [
            "expo-video",
            {
              "supportsBackgroundPlayback": true,
              "supportsPictureInPicture": true
            }
          ],
          [
            "expo-image-picker",
            {
              "cameraPermission": "$(PRODUCT_NAME) would like to use your camera to share image in a message.",
              "photosPermission": "$(PRODUCT_NAME) would like to use your device gallery to attach image in a message."
            }
          ],
          [
            "expo-media-library",
            {
              "photosPermission": "$(PRODUCT_NAME) would like access to your photo gallery to share image in a message.",
              "savePhotosPermission": "$(PRODUCT_NAME) would like to save photos to your photo gallery after downloading from a message."
            }
          ]
        ],
        ...
      }
    }
    

    This code adds the necessary permissions to the Info.plist on iOS devices.

  4. Add the react-native-reanimated plugin to your babel.config.js file in your application folder:

    module.exports = function (api) {
      api.cache(true);
      return {
        presets: [
          ['babel-preset-expo', { jsxImportSource: 'nativewind' }],
          'nativewind/babel',
        ],
        plugins: ['react-native-reanimated/plugin'],
      };
    };
    

With Stream Chat installed, let’s create the home layout for our Signal clone.

Building the Home Layout and Initializing Stream

The home layout will contain all screens a signed-in user can view. These screens will also need access to Stream, so we’ll initialize the Stream client within the layout file.

We need to set up a few things before we start building the layout.

First, let’s add a new (home) stack to the root layout. Open your app/_layout.tsx file and update it like so:

...

const RootLayout = () => {
  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      <ClerkProvider tokenCache={tokenCache} publishableKey={PUBLISHABLE_KEY}>
        <Stack>
          ...
          <Stack.Screen name="(home)" options={{ headerShown: false }} />
        </Stack>
        <StatusBar style="auto" />
      </ClerkProvider>
    </GestureHandlerRootView>
  );
};

export default RootLayout;

Next, let’s work on a few components. While the Stream client is connecting, we’ll need to show the user a loading screen.

Create a Spinner.tsx file in the components directory with the following snippet:

import { ActivityIndicator } from 'react-native';

interface SpinnerProps {
  color?: string;
}

const Spinner = ({ color = '#2c6bed' }: SpinnerProps) => {
  return <ActivityIndicator color={color} />;
};

export default Spinner;

Next, create a ScreenLoading.tsx file in the same folder with the following code:

import Screen from './Screen';
import Spinner from './Spinner';

const ScreenLoading = () => {
  return (
    <Screen className="bg-white" viewClassName="items-center justify-center">
      <Spinner />
    </Screen>
  );
};

export default ScreenLoading;

With our components ready, we can begin building our layout.

Create a (home) folder in the app directory, and then add a _layout.tsx file with the following code:

import { useUser } from '@clerk/clerk-expo';
import { Stack, useRouter } from 'expo-router';
import { useEffect, useState } from 'react';
import { StreamChat } from 'stream-chat';
import { Chat, OverlayProvider } from 'stream-chat-expo';

import ScreenLoading from '@/components/ScreenLoading';

const tokenProvider = async (userId: string) => {
  const response = await fetch('/token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ userId }),
  });
  const data = await response.json();
  return data.token;
};

const API_KEY = process.env.EXPO_PUBLIC_STREAM_API_KEY as string;

const HomeLayout = () => {
  const router = useRouter();
  const { user, isSignedIn } = useUser();
  const [loading, setLoading] = useState(true);
  const [chatClient, setChatClient] = useState<StreamChat>();

  useEffect(() => {
    if (!isSignedIn) {
      router.replace('/sign-in');
    }

    const customProvider = async () => {
      const token = await tokenProvider(user!.id);
      return token;
    };

    const setUpStream = async () => {
      try {
        const chatClient = StreamChat.getInstance(API_KEY);
        const clerkUser = user!;
        const chatUser = {
          id: clerkUser.id,
          name: clerkUser.fullName!,
          image: clerkUser.hasImage ? clerkUser.imageUrl : undefined,
          username: clerkUser.username!,
        };

        if (!chatClient.user) {
          await chatClient.connectUser(chatUser, customProvider);
        }

        setChatClient(chatClient);
      } catch (error) {
        console.error('Error setting up Stream:', error);
      } finally {
        setLoading(false);
      }
    };

    if (user) setUpStream();

    return () => {
      if (!isSignedIn) {
        chatClient?.disconnectUser();
      }
    };
  }, [user, chatClient, isSignedIn, router]);

  if (loading) return <ScreenLoading />;

  return (
    <OverlayProvider>
      <Chat client={chatClient!}>
        <Stack>
          <Stack.Screen
            name="(tabs)"
            options={{
              headerShown: false,
            }}
          />
        </Stack>
      </Chat>
    </OverlayProvider>
  );
};

export default HomeLayout;

There’s a lot going on here, so let’s break it down:

  • We create a tokenProvider function that fetches a user token from a /token endpoint. This token is required to authenticate and log in the user to Stream.

  • We use the isSignedIn state from Clerk’s useUser hook to check if a user is signed in. If not, we redirect them to the sign-in screen.

  • We define a setupStream function that creates a Stream client instance, connects the user to Stream, and then stores the client instance in the chatClient state.

  • We ensure the user is disconnected from the client when the component is unmounted.

  • While the client is connecting, we render the ScreenLoading component.

  • Once connected, we wrap our app in Stream’s <OverlayProvider> and <Chat> components:

    • The OverlayProvider allows users to interact with modals, message actions, and other UI elements provided by Stream.
    • The Chat component takes in the chatClient, which provides the Stream context to all other components.
  • Finally, we return a Stack navigator with a single (tabs) screen, which we’ll work on later.

Next, let’s create the route for the /token endpoint.

First, open your app.json file and update it with the following:

{
  "expo": {
    ...
    "web": {
      ...
      "output": "server",
      ...
    },
    ...
  }
}

This setting tells Expo to output a server bundle, which is required for API routes to work.

In Expo, API Routes can be defined by creating files in the app directory with the +api.ts extension.

For our /token route, create a token+api.ts file in the same directory with the following code:

import { StreamChat } from 'stream-chat';

const API_KEY = process.env.EXPO_PUBLIC_STREAM_API_KEY!;
const SECRET = process.env.STREAM_API_SECRET!;

export async function POST(request: Request) {
  const client = StreamChat.getInstance(API_KEY, SECRET);

  const body = await request.json();

  const userId = body?.userId;

  if (!userId) {
    return Response.error();
  }

  const token = client.createToken(userId);

  const response = {
    userId: userId,
    token: token,
  };

  return Response.json(response);
}

Here we generate a user token based on the provided userId and return it in a response.

💡
This web server setup is meant for development only. Stream’s React Native Video & Audio SDK does not currently support Expo’s web platform, so using this code in a production or release build will cause it to crash. For production environments, create your own backend server with an endpoint that generates a user token, and direct your fetch requests to that endpoint instead.

With our home layout set up, let’s build the tab screens.

Building the Tab Screens

The Signal app features three main tabs:

  • Chats: where conversations happen

  • Calls: for viewing recent calls

  • Stories: for short status updates

We’ll replicate this tab layout in our Signal clone. However, this tutorial will only cover the Chats screen in detail to keep things focused, while the Calls and Stories tabs will have placeholder UIs.

Building the Tabs layout

To get started, create a (tabs) folder in your (home) directory. Inside the folder, create a _layout.tsx file and add the following code:

import { Ionicons } from '@expo/vector-icons';
import FontAwesome from '@expo/vector-icons/FontAwesome';
import MaterialIcons from '@expo/vector-icons/MaterialIcons';
import { Tabs } from 'expo-router';

import { HapticTab } from '@/components/HapticTab';

const TabsLayout = () => {
  return (
    <Tabs
      screenOptions={{
        tabBarActiveTintColor: 'black',
        tabBarButton: HapticTab,
        tabBarStyle: {
          backgroundColor: 'white',
          borderTopColor: 'white',
        },
        headerTransparent: true,
        headerTitleAlign: 'center',
      }}
    >
      <Tabs.Screen
        name="chats"
        options={{
          title: 'Chats',
          tabBarIcon: ({ color }) => (
            <Ionicons name="chatbubble-sharp" size={24} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="calls"
        options={{
          title: 'Calls',
          tabBarIcon: ({ color }) => (
            <FontAwesome name="phone" size={28} color={color} />
          ),
        }}
      />
      <Tabs.Screen
        name="stories"
        options={{
          title: 'Stories',
          tabBarIcon: ({ color }) => (
            <MaterialIcons name="web-stories" size={28} color={color} />
          ),
        }}
      />
    </Tabs>
  );
};

export default TabsLayout;

This sets up our tab navigation and assigns icons to each tab.

Creating the Chats Screen

To understand how we’ll be building the chats screen, we first need to explain how channels work in Stream. Channels in Stream are objects that contain:

  • Messages exchanged between users.

  • A list of people watching the channel.

  • An optional list of members.

We’ll use these channels to represent group chats and DMs in our Signal clone.

Let’s start by creating the modules we’ll need for our screen.

Create an Avatar.tsx file in the components directory and add the following code:

import { Ionicons } from '@expo/vector-icons';
import { Text, TextStyle, View } from 'react-native';
import AppImage from './AppImage';

interface AvatarProps {
  name: string;
  imageUrl?: string;
  size?: number;
  fontSize?: TextStyle['fontSize'];
  fontWeight?: TextStyle['fontWeight'];
  placeholderType?: 'text' | 'icon';
}

const Avatar = ({
  imageUrl,
  size = 40,
  name,
  fontSize = 20,
  fontWeight = '500',
  placeholderType = 'text',
}: AvatarProps) => {
  if (imageUrl)
    return (
      <View
        className="relative flex shrink-0 overflow-hidden rounded-full"
        style={{ width: size, height: size }}
      >
        <AppImage
          source={{ uri: imageUrl }}
          className="w-full h-full"
          alt="name"
          contentFit="cover"
        />
      </View>
    );

  return (
    <View
      style={{
        width: size,
        height: size,
        backgroundColor: '#d8e8f0',
      }}
      className="shrink-0 rounded-full aspect-square flex flex-row items-center justify-center overflow-hidden"
    >
      {placeholderType === 'text' && (
        <Text
          style={{
            fontSize,
            fontWeight,
          }}
          className="leading-[2] text-[#086da0] uppercase"
        >
          {name ? name[0] : ''}
        </Text>
      )}
      {placeholderType === 'icon' && (
        <Ionicons name="people-outline" size={fontSize} color="#086da0" />
      )}
    </View>
  );
};

export default Avatar;

This component displays a user’s avatar. If the user has an image, it renders it. If not, it shows either the first letter of their name or an icon.

Next, create an AppMenu.tsx file in the components folder and add the following snippet:

import { useClerk, useUser } from '@clerk/clerk-expo';
import { Feather } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useRef, useState } from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';

import { getError } from '../lib/utils';
import Avatar from './Avatar';
import Button from './Button';

const AppMenu = () => {
  const { signOut } = useClerk();
  const router = useRouter();
  const { user } = useUser();
  const [menuVisible, setMenuVisible] = useState(false);
  const [menuPosition, setMenuPosition] = useState({ top: 0, left: 0 });
  const avatarRef = useRef<View>(null);

  const toggleMenu = () => {
    if (menuVisible) {
      setMenuVisible(false);
      return;
    }
    if (avatarRef.current) {
      avatarRef.current.measure((_x, _y, _width, height, pageX, pageY) => {
        setMenuPosition({ top: pageY + height + 8, left: pageX - 8 });
        setMenuVisible(true);
      });
    }
  };

  const goToProfile = () => {
    setMenuVisible(false);
    router.push('/profile');
  };

  const handleSignOut = async () => {
    setMenuVisible(false);
    try {
      await signOut();
      router.replace('/');
    } catch (err) {
      getError(err);
    }
  };

  return (
    <>
      <Button variant="plain" ref={avatarRef} onPress={toggleMenu}>
        <Avatar
          imageUrl={user?.imageUrl}
          size={28}
          fontSize={12}
          name={user?.fullName!}
        />
      </Button>
      <Modal
        transparent
        visible={menuVisible}
        animationType="fade"
        onRequestClose={() => setMenuVisible(false)}
      >
        <Pressable
          style={StyleSheet.absoluteFill}
          onPress={() => setMenuVisible(false)}
        >
          <View
            style={{ top: menuPosition.top, left: menuPosition.left }}
            className="absolute bg-white rounded-lg shadow-md shadow-gray-100 min-w-[250px]"
          >
            <Button
              variant="plain"
              className="flex-row items-center justify-between py-2 px-3 border-b border-gray-200"
              onPress={goToProfile}
            >
              <Text>Profile</Text>
              <Feather name="user" size={20} color="black" />
            </Button>
            <Button
              variant="plain"
              className="flex-row items-center justify-between py-2 px-3"
              onPress={handleSignOut}
            >
              <Text className="text-red-600">Sign Out</Text>
              <Feather name="log-out" size={20} color="red" />
            </Button>
          </View>
        </Pressable>
      </Modal>
    </>
  );
};

export default AppMenu;

Here’s what the AppMenu component does:

  • Displays the user’s avatar using the Avatar component, positioned using a ref to calculate where the menu should appear on the screen.

  • Toggles a custom dropdown menu (built with Modal) that appears below the avatar.

  • Inside the menu, there are two options:

    • Profile: Navigates the user to the profile screen.
    • Sign Out: Logs the user out using Clerk and redirects to the home screen.

Next, navigate to the lib folder and update the utils.ts file with the following code:

import type { Channel } from 'stream-chat';

export const getError = (err: any) => {
  ...
};

export const checkIfDMChannel = (channel: Channel) => {
  return !!channel?.id?.startsWith('!members');
};

Here we define a checkIfDMChannel function to determine if a channel is a direct message (DM) between two users. We do this by checking if the channel ID starts with the substring !members. Channels with this ID pattern are referred to as ‘distinct channels,’ and only one of them can exist between their specified members. This makes them the perfect way to implement DMs in our app.

Next, create a PreviewAvatar.tsx file in the components directory with the following code:

import {
  ChannelAvatarProps,
  useChannelPreviewDisplayAvatar,
} from 'stream-chat-expo';

import { checkIfDMChannel } from '@/lib/utils';
import Avatar from './Avatar';

export interface PreviewAvatarProps extends ChannelAvatarProps {
  size?: number;
  fontSize?: number;
}

const PreviewAvatar = ({
  channel,
  size = 44,
  fontSize = 20,
}: PreviewAvatarProps) => {
  const isDMChannel = checkIfDMChannel(channel);
  const displayAvatar = useChannelPreviewDisplayAvatar(channel);
  const placeholderType = isDMChannel ? 'text' : 'icon';

  return (
    <Avatar
      size={size}
      name={displayAvatar.name!}
      fontSize={fontSize}
      imageUrl={isDMChannel ? displayAvatar.image : undefined}
      placeholderType={placeholderType}
    />
  );
};

export default PreviewAvatar;

The PreviewAvatar component is used to display the appropriate avatar for a channel.

Here’s how it works:

  • It detects whether the channel is a direct message (DM) using the checkIfDMChannel function we created.

  • If it’s a DM, it displays the other user’s avatar.

  • If it’s a group or team channel, it defaults to showing an icon instead.

  • It uses the useChannelPreviewDisplayAvatar hook from stream-chat-expo to get the display name and avatar.

  • The actual rendering is done by the Avatar component.

With our modules set up, let’s move on to the chats screen.

In the (tabs) folder, create a chats.tsx file and add the following code:

import Feather from '@expo/vector-icons/Feather';
import { Link, useRouter } from 'expo-router';
import { View } from 'react-native';
import { Channel } from 'stream-chat';
import { ChannelList, useChatContext } from 'stream-chat-expo';

import AppMenu from '@/components/AppMenu';
import Button from '@/components/Button';
import PreviewAvatar from '@/components/PreviewAvatar';
import Screen from '@/components/Screen';
import ScreenLoading from '@/components/ScreenLoading';

const ChatsScreen = () => {
  const { client } = useChatContext();
  const router = useRouter();

  const goToChannel = (channel: Channel) => {
    router.navigate({
      pathname: '/chat/[id]',
      params: { id: channel.id! },
    });
  };

  return (
    <Screen className="bg-white" viewClassName="px-4">
      <View className="flex flex-row items-center justify-between w-full h-10">
        <AppMenu />
        <View className="flex flex-row items-center gap-4">
          <Button variant="plain">
            <Feather name="camera" size={20} />
          </Button>
          <Link href="/new-message" asChild>
            <Button variant="plain" className="pl-4 py-1">
              <Feather name="edit" size={18} />
            </Button>
          </Link>
        </View>
      </View>
      <ChannelList
        filters={{
          type: 'messaging',
          members: { $in: [client.userID!] },
        }}
        sort={{ last_message_at: -1 }}
        onSelect={goToChannel}
        LoadingIndicator={ScreenLoading}
        PreviewAvatar={PreviewAvatar}
      />
    </Screen>
  );
};

export default ChatsScreen;

Here we render a top nav that contains the AppMenu and an edit button that links to /new-message where users can start a new chat. We also list all the user’s channels using Stream’s ChannelList component. This component is given the following props:

  • filters: We set this to only show messaging channels that include the logged-in user.

  • sort: We use this prop to sort the channels by the latest message.

  • onSelect: When a channel is tapped, we run the goToChannel function. This function dynamically routes the user to a chat screen for that channel using its ID.

  • LoadingIndicator: We pass our custom ScreenLoading component to show when the ChannelList is loading.

  • PreviewAvatar: We pass our custom PreviewAvatar component.

Our app currently doesn’t have any existing channels, so the ChannelList returns an empty state indicator.

Chats screen intial

Creating the Calls and Stories Screens

Next, let’s create the calls and stories screen with placeholder UIs.

Create a calls.tsx file in the (tabs) folder with the following code:

import { Feather } from '@expo/vector-icons';
import { Text, View } from 'react-native';

import AppMenu from '@/components/AppMenu';
import Button from '@/components/Button';
import Screen from '@/components/Screen';

const CallsScreen = () => {
  return (
    <Screen className="bg-white" viewClassName="px-4 items-start">
      <View className="flex flex-row items-center justify-between w-full h-10">
        <AppMenu />
        <View className="flex flex-row items-center gap-8">
          <Button variant="plain">
            <Feather name="phone" size={20} color="black" />
          </Button>
        </View>
      </View>
      <Button
        variant="plain"
        className="flex flex-row items-center justify-center gap-4 mt-4"
      >
        <Feather name="link" size={20} color="black" />
        <Text className="font-semibold">Create a Call Link</Text>
      </Button>
      <View className="flex-1 w-full flex-col items-center justify-center mt-8">
        <Text className="font-semibold">No recent calls</Text>
        <Text className="text-sm text-gray-600">
          Get started by calling a friend
        </Text>
      </View>
    </Screen>
  );
};

export default CallsScreen;

Calls screen

Next, create a stories.tsx file in the same folder and add the following code:

import { useUser } from '@clerk/clerk-expo';
import { Feather } from '@expo/vector-icons';
import { Text, View } from 'react-native';

import AppMenu from '@/components/AppMenu';
import Avatar from '@/components/Avatar';
import Button from '@/components/Button';
import Screen from '@/components/Screen';

const StoriesScreen = () => {
  const { user } = useUser();

  return (
    <Screen className="bg-white" viewClassName="px-4 pt-1">
      <View className="flex flex-row items-center justify-between w-full h-8">
        <AppMenu />
        <View className="flex flex-row items-center gap-8">
          <Button variant="plain">
            <Feather name="camera" size={20} color="black" />
          </Button>
        </View>
      </View>
      <Button variant="plain" className="flex-row items-center gap-3 mt-4">
        <View className="relative w-10 h-10">
          <Avatar
            imageUrl={user?.imageUrl}
            size={40}
            fontSize={16}
            name={user?.fullName!}
          />
          <View className="absolute -bottom-1 -right-1 w-[22px] h-[22px] rounded-full border-[3px] border-white bg-blue-600 flex items-center justify-center">
            <Feather name="plus" size={14} color="white" />
          </View>
        </View>
        <View>
          <Text className="font-semibold">My Stories</Text>
          <Text className="text-xs text-gray-500">Tap to add</Text>
        </View>
      </Button>
    </Screen>
  );
};

export default StoriesScreen;

Stories screen

And with that, we’ve successfully built our tab screens!

Conclusion

At this point, we’ve successfully set up our core navigation, integrated secure user authentication with Clerk, and prepared Stream’s React Native Chat SDK to handle chat functionality in our app.

In part two, we’ll build upon this foundation by creating a fully interactive chat experience, complete with real-time messaging, audio and video calls, and screens for managing user profiles and initiating conversations.

Stay tuned!


This content originally appeared on DEV Community and was authored by Oluwabusayo Jacobs