Back to blog

Building a Twitter Spaces Clone with NativeBase and 100ms

Vipul Bhardwaj· Senior Software Engineer·January 25, 2022·5 min read
Building a Twitter Spaces Clone with NativeBase and 100ms

Introduction

As part of community initiatives at NativeBase, the team partnered with 100ms for a workshop on creating a Twitter Spaces clone. This article serves as a companion to the video version.

Bootstrapping the project

NativeBase provides universal templates across all target platforms, drastically reducing setup time. The framework offers a pre-configured, ready-to-extend foundation for development.

Following the NativeBase Installation guide, developers can begin with the "react-native" template by executing a few simple commands.

Building the Screens

The demo application features two screens: a home screen displaying live spaces and a detailed space view. The home screen utilizes an attractive card component showing multiple details.

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>
  );
}

Everything is a token

Components in NativeBase leverage a comprehensive design system with professional, tested styling tokens. This extensibility supports brand identity customization. Developers can use tokens like w="full", bg="fuchsia.800", overflow="hidden", and borderRadius="16" through "Utility Props," providing superior developer experience compared to traditional React Native StyleSheet approaches.

Color Modes and Accessibility

NativeBase provides built-in light and dark mode support. Using Pseudo Props, props beginning with an underscore, developers control conditional styling. For example:

<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>

The framework uses react-native-aria, ensuring all components are accessible by default without additional effort.

Adding Functionality

The 100ms SDK for React Native simplifies connecting static UI screens to functional features. The SDK is straightforward to configure with comprehensive documentation.

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>
</>

The process involves joining a room with a specified ID, fetching authentication tokens via API endpoint, constructing an HMSConfig object, and establishing a connection. Once connected, the system fires events when participants join or update their state, enabling reactive UI changes. Full SDK details are available in the documentation.

Final Product

The demonstration produces a functional Twitter Spaces clone with minimal features. Developers can expand this foundation with additional capabilities to create a production-ready, feature-complete application.

Resources