Building a Twitter Spaces Clone with NativeBase and 100ms
View more posts
January 25, 2022 5 minute read

Building a Twitter Spaces Clone with NativeBase and 100ms

Vipul Bhardwaj
Senior Software Engineer

Introduction

As part of our community initiatives at NativeBase, we partnered with 100ms to do a workshop on “Building a Twitter Spaces Clone,” which you can find in the video version. This article is written as a follow-along reading for the same.

Bootstrapping the project

One of the great things about using NativeBase is its universal character. You get a template for all the possible target platforms you might be thinking of building an app. This means that the bootstrapping time is reduced drastically. You also get an all-configured, basic app that is ready to be extended.
 
Following the NativeBase Installation guide https://docs.nativebase.io/installation, we will start by using the “react-native” template, and it's as easy as copy-pasting a few commands described in the instructions.

Building the Screens

The demo app we are building has two screens. You see the home screen when you launch the app. This screen shows you all the live spaces. The card component on this screen is attractive. It has details of showing several things and is of moderate complexity.
Let's look at how NativeBase makes building UI's like this a cake-walk.
notion image
 

SpaceCard Component

import React from 'react';
import { Box, Text, HStack, Avatar, Pressable } from 'native-base';

export default function (props) {
  return (
    <Pressable
      w="full"
      bg="fuchsia.800"
      overflow="hidden"
      borderRadius="16"
      onPress={props.onPress}
    >
      <Text px="4" my="4" fontSize="md" color="white">
        Live
      </Text>
      <Text w="80%" pl="4" mb="4" fontSize="xl" color="white">
        Building a Twitter Space Clone in React Native using NativeBase and
        100ms
      </Text>
      <HStack p="4" bg="fuchsia.900" space="4">
        <Box flexDirection="row" justifyContent="center" alignItems="center">
          <Avatar
            size="sm"
            alignSelf="center"
            bg="green.200"
            source={{
              uri: 'https://pbs.twimg.com/profile_images/1188747996843761665/8CiUdKZW_400x400.jpg',
            }}
          >
            VB
          </Avatar>
          <Box ml="4">
            <Text fontSize="sm" color="white">
              Vipul Bhardwaj
            </Text>
            <Text fontSize="sm" color="white">
              SSE @GeekyAnts
            </Text>
          </Box>
        </Box>
        <Box flexDirection="row" justifyContent="center" alignItems="center">
          <Avatar
            size="sm"
            alignSelf="center"
            bg="green.200"
            source={{
              uri: 'https://pbs.twimg.com/profile_images/1188747996843761665/8CiUdKZW_400x400.jpg',
            }}
          >
            HO
          </Avatar>
          <Box ml="4">
            <Text fontSize="sm" color="white">
              Host
            </Text>
            <Text fontSize="sm" color="white">
              SE @100ms
            </Text>
          </Box>
        </Box>
      </HStack>
    </Pressable>
  );
}
Yup, that's it. That's the whole code. This is how easy NativeBase makes everything 🤯.
Let's look at the code in detail and learn about some of the tiny details that make it even more awesome.

Everything is a token

Every component in NativeBase is styled using its comprehensive, professionally designed, and tested Design System. All of them are created to be extensible to represent the brand identity of your app. This allows you to use tokens available in the NativeBase theme.
And thus, we can use values like w="full", bg="fuchsia.800" , overflow="hidden" , borderRadius="16" all of which are tokens assigned to props. This style of passing styles props as individual values is known as “Utility Props.” It provides a great Developer Experience and NativeBase embraces this idea fully. It uses “Utility Props”, instead of the default react-native StyleSheet approach.
https://docs.nativebase.io/default-theme
 

Color Modes and Accessibility

NativeBase supports both light and dark mode out of the box. All the inbuilt components are designed to work with both color modes. But what if you use something other than the default values. With NativeBase, using Pseudo Props this becomes terribly easy.
Let's look at an example, this is the JSX code for the HomeScreen, notice on line 1, we have _light, and _dark. In NativeBase, props that start with an underscore are called pseudo props and they are used to control conditional styling. In the case of light and dark modes, you can use these props to provide styles that will only apply when the color mode is light or dark.
Yes, it's that easy to add support for the dark mode to your components. On top of that, NativeBase used react-native-aria, so all the components are accessible by default, without you needing to do anything extra.
<Box flex="1" _light={{ bg: 'white' }} _dark={{ bg: 'darkBlue.900' }}>
  <VStack space="2" p="4">
    <Heading>Happening Now</Heading>
    <Text>Spaces going on right now</Text>
  </VStack>
  <ScrollView p="4">
    <VStack space="8">
      <SpaceCard
        onPress={() =>
          navigation.navigate('Space', {
            roomID: 'your-room-id-here',
          })
        }
      />   
    </VStack>
  </ScrollView>
</Box>

Adding Functionality

We use the 100ms SDK for react-native, which makes it extremely easy to get your add from just a collection of UI screens with static data to a full-blown functional app. The SDK is easy to set up and the documentation is great.
const fetchToken = async ({ roomID, userID, role }) => {
  const endPoint =
    'https://prod-in.100ms.live/hmsapi/geekyants.app.100ms.live/api/token';

  const body = {
    room_id: roomID,
    user_id: userID,
    role: role,
  };

  const headers = {
    'Content-Type': 'application/json',
    Accept: 'application/json',
  };

  const response = await fetch(endPoint, {
    method: 'POST',
    body: JSON.stringify(body),
    headers,
  });

  const result = await response.json();
  return result;
};

async function joinRoom(hmsInstance, roomID, userID) {
  if (!hmsInstance) {
    console.error('HMS Instance not found');
    return;
  }

  const { token } = await fetchToken({
    roomID,
    userID,
    role: 'speaker',
  });

  const hmsConfig = new HMSConfig({ authToken: token, username: userID });

  hmsInstance.join(hmsConfig);
}

export default function Space({ navigation, route }) {
  const hmsInstance = useContext(HMSContext);
  const [isMute, setMute] = useState(false);
  const [participants, setParticipants] = useState([]);

  const userID = useRef('demouser').current;
  const roomID = useRef(route.params.roomID).current;

  useEffect(() => {
    if (hmsInstance) {
      hmsInstance.addEventListener(HMSUpdateListenerActions.ON_ERROR, (data) =>
        console.error('ON_ERROR_HANDLER', data)
      );

      hmsInstance.addEventListener(
        HMSUpdateListenerActions.ON_JOIN,
        ({ room, localPeer, remotePeers }) => {
          const localParticipant = {
            id: localPeer?.peerID,
            name: localPeer?.name,
            role: localPeer?.role?.name,
            avatar: (
              <Circle w="12" h="12" p="2" bg="blue.600">
                {localPeer?.name?.substring(0, 2)?.toLowerCase()}
              </Circle>
            ),
            isMute: localPeer?.audioTrack?.isMute(),
          };

          const remoteParticipants = remotePeers.map((remotePeer) => {
            return {
              id: remotePeer?.peerID,
              name: remotePeer?.name,
              role: remotePeer?.role?.name,
              avatar: (
                <Circle w="12" h="12" p="2" bg="blue.600">
                  {remotePeer?.name?.substring(0, 2)?.toLowerCase()}
                </Circle>
              ),
              isMute: remotePeer?.audioTrack?.isMute(),
            };
          });

          setParticipants([localParticipant, ...remoteParticipants]);
        }
      );

      hmsInstance.addEventListener(
        HMSUpdateListenerActions.ON_ROOM_UPDATE,
        (data) => console.log('ON ROOM UPDATE', data)
      );

      hmsInstance?.addEventListener(
        HMSUpdateListenerActions.ON_PEER_UPDATE,
        ({ localPeer, remotePeers }) => {
          const localParticipant = {
            id: localPeer?.peerID,
            name: localPeer?.name,
            role: localPeer?.role?.name,
            avatar: (
              <Circle w="12" h="12" p="2" bg="blue.600">
                {localPeer?.name?.substring(0, 2)?.toLowerCase()}
              </Circle>
            ),
            isMute: localPeer?.audioTrack?.isMute(),
          };

          const remoteParticipants = remotePeers.map((remotePeer) => {
            return {
              id: remotePeer?.peerID,
              name: remotePeer?.name,
              role: remotePeer?.role?.name,
              avatar: (
                <Circle w="12" h="12" p="2" bg="blue.600">
                  {remotePeer?.name?.substring(0, 2)?.toLowerCase()}
                </Circle>
              ),
              isMute: remotePeer?.audioTrack?.isMute(),
            };
          });

          setParticipants([localParticipant, ...remoteParticipants]);
        }
      );

      hmsInstance?.addEventListener(
        HMSUpdateListenerActions.ON_TRACK_UPDATE,
        ({ localPeer, remotePeers }) => {
          const localParticipant = {
            id: localPeer?.peerID,
            name: localPeer?.name,
            role: localPeer?.role?.name,
            avatar: (
              <Circle w="12" h="12" p="2" bg="blue.600">
                {localPeer?.name?.substring(0, 2)?.toLowerCase()}
              </Circle>
            ),
            isMute: localPeer?.audioTrack?.isMute(),
          };

          const remoteParticipants = remotePeers.map((remotePeer) => {
            return {
              id: remotePeer?.peerID,
              name: remotePeer?.name,
              role: remotePeer?.role?.name,
              avatar: (
                <Circle w="12" h="12" p="2" bg="blue.600">
                  {remotePeer?.name?.substring(0, 2)?.toLowerCase()}
                </Circle>
              ),
              isMute: remotePeer?.audioTrack?.isMute(),
            };
          });

          setParticipants([localParticipant, ...remoteParticipants]);
        }
      );
    }

    joinRoom(hmsInstance, roomID, userID);
  }, [hmsInstance, roomID, userID]);
}
 
<>
  <VStack
    p="4"
    flex="1"
    space="4"
    _light={{ bg: "white" }}
    _dark={{ bg: "darkBlue.900" }}
  >
    <HStack ml="auto" alignItems="center">
      <IconButton
        variant="unstyled"
        icon={<HamburgerIcon _dark={{ color: "white" }} size="4" />}
      />
      <Button variant="unstyled">
        <Text fontSize="md" fontWeight="bold" color="red.600">
          Leave
        </Text>
      </Button>
    </HStack>
    <Text fontSize="xl" fontWeight="bold">
      Building a Twitter Space Clone in React Native using NativeBase and 100ms
    </Text>
    <FlatList
      numColumns={4}
      ListEmptyComponent={<Text>Loading...</Text>}
      data={participants}
      renderItem={({ item }) => (
        <VStack w="25%" p="2" alignItems="center">
          {item.avatar}
          <Text numberOfLines={1} fontSize="xs">
            {item.name}
          </Text>
          <HStack alignItems="center" space="1">
            {item.isMute && (
              <Image
                size="3"
                alt="Peer is mute"
                source={require("../icons/mute.png")}
              />
            )}
            <Text numberOfLines={1} fontSize="xs">
              {item.role}
            </Text>
          </HStack>
        </VStack>
      )}
      keyExtractor={(item) => item.id}
    />
  </VStack>
  <HStack
    p="4"
    zIndex="1"
    safeAreaBottom
    borderTopWidth="1"
    alignItems="center"
    _light={{ bg: "white" }}
    _dark={{ bg: "darkBlue.900" }}
  >
    <VStack space="2" justifyContent="center" alignItems="center">
      <Pressable
        onPress={() => {
          hmsInstance.localPeer.localAudioTrack().setMute(!isMute);
          setMute(!isMute);
        }}
      >
        <Circle p="2" borderWidth="1" borderColor="coolGray.400">
          {isMute ? (
            <Image
              size="8"
              key="mic-is-off"
              alt="mic is off"
              resizeMode={"contain"}
              source={require("../icons/mic-mute.png")}
            />
          ) : (
            <Image
              size="8"
              key="mic-is-on"
              alt="mic is on"
              resizeMode={"contain"}
              source={require("../icons/mic.png")}
            />
          )}
        </Circle>
      </Pressable>
      <Text fontSize="md">{isMute ? "Mic is off" : "Mic is on"}</Text>
    </VStack>
    <HStack ml="auto" mr="4" space="5">
      <Image
        size="7"
        alt="Participant Icon"
        source={require("../icons/users.png")}
      />
      <Image
        size="7"
        alt="Emojie Icon"
        source={require("../icons/heart.png")}
      />
      <Image size="7" alt="Share Icon" source={require("../icons/share.png")} />
      <Image
        size="7"
        alt="Tweet Icon"
        source={require("../icons/feather.png")}
      />
    </HStack>
  </HStack>
</>
 
We first join the room with room id. Then we fetch the authentication tokens by hitting the URL and creating an HMSConfig object, which we will use to connect to the room. Once we establish a connection, we will get events based on calls when things happen in the room.
For example, when some peer/user joins the room, we will get an event, and based on that, we can change the state of our data, which will lead to changes reflected in the UI. You can read more about it on the SDK and all the details of different things in the SDK documentation ()

Final Product

There we have it, a working demo of a feature minimal Twitter spaces clone. You can add many features to extend this and build an incredibly cool and feature-rich app ready for use in the real world 🙂.
 

Resources