Mohammad Kashif Sulaiman Mobile Apps Developer

Mimic WhatsApp stories using React Native and Firestore

6 min read 1875

Mimic WhatsApp Stories Using React Native And Firestore

Stories are now a trending feature of most social media applications, including WhatsApp, Snapchat, Instagram, and others. This feature gives us another avenue to share media in the form of images, videos, and text to your contacts or friends, and let you know who viewed your story. One of the more appealing aspects of stories is that they are impermanent — they are usually viewable for only 24 hours.

Yeah I Know GIF

So if you know, why are you here?

Oh! I got it. You need the tutorial on how to develop your own stories feature using React Native and Firestore! Let’s get started.

I’ve configured the basic project setup with React Navigation, Redux and Firebase Authentication, and the Firestore database. Let’s review the database structure before moving forward!

users<userIds><userData>

The Users Collection In Our Database

users<userId>stories<storyId><storyData>

The Stories Collection In Our Database

Let’s start!

Now, we have to achieve three targets:

  1. Add your story/status
  2. List all of the user’s statuses
  3. View all of the user’s statuses

So let’s start with the first point!

1.) Add your story/status

Let’s start by picking some images from Expo’s Image Picker and converting them into a blob in order to upload to Firebase Storage and upload/add records to Firestore collections.

AddStory.js

  _handleSelectImage = async () => {
    let result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: "Images"
    });
    if (!result.cancelled) {
      this.setState({ image: result.uri });
    }
  };

  _handleSubmit = async () => {
    const { userId } = this.props;
    const { image, title } = this.state;

    if (image) {
        try {
          // Converting image to blob
          const image = await blobMaker(image);

          // Uploading image in Firebase storage
          const tempImage = await firebase
            .storage()
            .ref()
            .child(`images/${new Date().getTime()}.jpg`)
            .put(image);
          const imageURL = await tempImage.ref.getDownloadURL();
          const createdAt = firebase.firestore.Timestamp.now().toMillis();
          // Preparing object to be pushed in Firestore
          const payload = {
            image: imageURL,
            viewedBy: [],
            createdAt
          };
          if (title) {
            payload.title = title;
          }
          // Pushing story data into `stories` subcollection of collection `users`
          await firebase
            .firestore()
            .collection("users")
            .doc(userId)
            .collection("stories")
            .add(payload);
          // And updating the last story time in user's document, this will help us to sort by latest story in the list screen
          await firebase
            .firestore()
            .collection("users")
            .doc(userId)
            .set(
              {
                updatedAt: createdAt
              },
              { merge: true }
            );

          this.props.navigation.navigate("Stories")
        } catch (error) {

          this.setState({ loading: false });
        }
      }
    }
  };

  render() {
    <ScrollView contentContainerStyle={styles.container}>
        {/* Title View */}
        <View style={styles.inputContainer}>
            <Text>Title (Optional)</Text>
            <TextInput
              style={styles.input}
              value={title}
              onChangeText={title => this.setState({ title })}
            />
        </View>
        {/* Image View */}
        <View style={styles.buttonContainer}>
            <Button
              title={image ? "Change Image" : "Select Image"}
              style={styles.button}
              onPress={this._handleSelectImage}
            />
            {image && <Image source={{uri: image}} style={styles.image}/>}
        </View>
        {/* Submit View */}
        <View style={styles.buttonContainer}>
            <Button
              title="Submit"
              style={styles.button}
              onPress={this._handleSubmit}
            />
        </View>
    </ScrollView>
}

Adding An Image To Our Story
Congratulations! We are done with uploading our very first image/story to Firebase storage and updating the record in Firestore. Now let’s move to the second target.

2.) List all of the user’s statuses

So, we have added records to the Firestore user collections. Now let’s get those records. First, we need to make a Firebase query for all user collections with Snapshot. Why Snapshot, you ask? Because we need real-time data for all users.

AllStories.js

listenAllUsers = async () => {
    const { userId } = this.props;
    try {
      // Listening to users collections
      await firebase
        .firestore()
        .collection("users")
        .onSnapshot(snapshot => {
          if (!snapshot.empty) {
            let user;
            let allUsers = [];

            snapshot.forEach(snap => {
              const data = { ...snap.data(), _id: snap.id };
              if(data._id === userId) {
                user = data;
              }
              else {
                allUsers.push(data);
              }
            });
            this.setState({ allUsers, user });
          }
        });
    } catch (error) {
      console.log("listenAllUsers-> error", error);
    }
  };

Now that we have all the users, let’s save them for later by updating state. Our goal is to get all users who have stories within the last 24 hours — so what should we do?

We have to filter those from all users with an interval loop that will rerun the function so that we get the story statuses up to date.

componentDidMount() {
    // Listening for all users
    this.listenAllUsers();
    // Interval
    this.timeIntervalSubscription = setInterval(() => {
      if (this.state.allUsers.length) {
        // Filtering all users 
        this.filterUsers();
      }
    }, 500);
}  

filterUsers = () => {
    const { allUsers } = this.state;
    const filterUsers = allUsers.filter(user => dateIsWithin24Hours(user.updatedAt));

    this.setState({ filterUsers });
};

Now we just need to render the things. I’ve created my own styling component (AvatarWithStory) to render them — you can try your own!

  render() {
    const { user, filterUsers, allUsers } = this.state;
    return (
      <ScrollView contentContainerStyle={styles.container}>
        {/* My story */}
        <View style={styles.containerWithPadding}>
            <AvatarWithStory
              hasStories={dateIsWithin24Hours(user.updatedAt)}
              user={{ ...user, time: dateFormatter(user.updatedAt) }}
            />
          )}
        </View>
        <HrWithText text={`Other Users (${filterUsers.length})`} />
        {/* All users */}
        <View style={styles.containerWithPadding}>
          {filterUsers &&
            filterUsers.map(user => (
             <AvatarWithStory
                user={{ ...user, time: dateFormatter(user.updatedAt) }}
             />
            ))}
        </View>
      </ScrollView>
    );
  }
}

List Of User Stories

Congrats! We have just hit our second target. Now let’s move on to the last target.

3.) View all of the user’s statuses/stories

Now we are in the very last phase of our app: we need to render selected user stories/statuses. Considering that we are getting the user ID from props or the selected user’s navigation params, all we need to do is query that and get data from its sub-collection.

For swiping images, I’m using react-native-banner-carousel.

Story.js

componentDidMount() {
    // Listening for the selected user story
    this.fetchSelectUserStory();
}  

fetchSelectUserStory = async () => {
    // Updating currentIndex from -1 to 0 in order to start stories
    this.setState(pre => ({ ...pre, currentIndex: pre.currentIndex + 1 }));
    // Previous 24 hours server time
    const currentTimeStamp =
      firebase.firestore.Timestamp.now().toMillis() - 24 * 60 * 60 * 1000;
    try {
      // Listening for selected users sub-collections of stories where createdAt is greater than currentTimeStamp
      const tempStories = await firebase
        .firestore()
        .collection("users")
        .doc(this.props.navigation.state.params.id) // Here considering userId is from navigation props
        .collection("stories")
        .orderBy("createdAt", "asc")
        .where("createdAt", ">", currentTimeStamp)
        .get();
      if (!tempStories.empty) {
        const stories = [];
        tempStories.forEach(story => {
          stories.push({
            ...story.data(),
            id: story.id
          });
        });
        // Updating state according to fetched stories
        this.setState({ stories });
        // Changing slide 
        this.interval();
      }
    } catch (error) {
      console.log("fetchSelectUserStory -> error", error);
    }
  };

Like WhatsApp, we can check who has seen my story, an awesome feature! So let’s add that, too, in our application. When users view my story, all we need to do is update the Firestore sub-collection with those users’ IDs.

// Will run on page change
onPageChanged = async index => {
    const { stories } = this.state;
    const { userId } = this.props;
    // Getting active story from state 
    const activeStory = stories[index];
    // Updating currentIndex 
    this.setState({ currentIndex: index });
    // Changing slide
    this.interval();
    // Checking whether user already viewed the story
    const alreadyViewed = activeStory.viewedBy.filter(
      user => user === userId
    );
    // If already viewed, return from function
    if (alreadyViewed.length) {
      return;
    }
    // If not, then update record in Firestore 
    try {
      await firebase
        .firestore()
        .collection("users")
        .doc(this.props.id)
        .collection("stories")
        .doc(activeStory.id)
        .set(
          {
            viewedBy: [...activeStory.viewedBy, this.props.userId]
          },
          { merge: true }
        );
    } catch (error) {
      console.log("TCL: Story -> error", error);
    }
  };

Let’s also add auto-swipe to the story for a more natural feel. What about 10s? I think that’s too much — let’s just stick to 6s.

interval = () => {
    // Clearing timeout if previous is in subscription
    if (this.clearTimeOut) clearTimeout(this.clearTimeOut);

    // New subscription for current slide
    this.clearTimeOut = setTimeout(() => {
      const { currentIndex, stories} = this.state;
      // If current slide is the last slide, then remove subscription
      if (Number(currentIndex) === Number(stories.length) - 1) {
        clearTimeout(this.clearTimeOut);
      } else {
        // Updating current slide by 1
        this.setState({ currentIndex: currentIndex + 1 });
        // Checking if carousel exists (ref: check <Carousel /> in render())
        if (this._carousel) {
          const { currentIndex} = this.state;
          // If yes, then move to next slide
          this._carousel.gotoPage(currentIndex);
        }

      }
    }, 6000);
  };

Have a look at our render functions:

  // Render single slide
  renderPage = (story, index) => {
    // Changing slide on press 
    const onPress = () => 
      {
          this.setState(pre => ({
            ...pre,
            currentIndex:
              pre.currentIndex === pre.stories.length ? 0 : pre.currentIndex + 1
          }));
          this._carousel.gotoPage(this.state.currentIndex);
          this.interval();
        }
      return (
        <TouchableOpacity
          onPress={onPress}
        >
          <View key={index}>
            <Image source={{ uri: story.image }} />
            {story.title && (
              <View>
                <Text style={styles.overlayText} numberOfLines={3}>
                  {story.title}
                </Text>
              </View>
            )}
          </View>
        </TouchableOpacity>
      );
    };
  // Pause slider function
  pauseSlider = () => clearTimeout(this.clearTimeOut);
  // Go back to screen
  goBack = () => this.props.navigation.navigate("StoriesScreen");
  // Close modal
  closeModal =() => 
    {
      this.setState({ modalVisible: false });
      this.interval();
    }
  render() {   
    const { currentIndex, stories, isLoading, stories } = this.state;

    return (
        <View style={styles.container}>
        {/* Header View */}
          <View style={styles.topContainer}>
            {/* Progress Bars on the top of story. See the component below */}
            <TopBar
              index={currentIndex}
              totalStories={stories.length}
              isLast={currentIndex === stories.length- 1}
            />
            <Header
              goBack={this.goBack}
              user={this.props.user}
              views={
                stories[currentIndex] && stories[currentIndex].viewedBy.length
              }
              viewsOnPress={this.setModalVisible}
            />
          </View>
          {/* Carousel Images View */}
          <View style={styles.bottomContainer}>
            <Carousel
              ref={ref => (this._carousel = ref)}
              autoplay={false}
              loop={false}
              pageSize={BannerWidth}
              onPageChanged={this.onPageChanged}
              index={currentIndex === -1 ? 0 : currentIndex}
              showsPageIndicator={false}
            >
              {stories.map((story, index) => this.renderPage(story, index))}
            </Carousel>
          </View>
        </View>
        {/* Viewed By View */}
        <Modal
          animationType="slide"
          transparent={false}
          visible={this.state.modalVisible}
          onRequestClose={() => {
            this.setState({ modalVisible: false });
            this.interval();
          }}
        >
          <ScrollView>
            <View style={styles.viewedBy}>
              <Text>Viewed By</Text>
              <TouchableOpacity
                onPress={this.closeModal}
              >
                <Text>Close</Text>
              </TouchableOpacity>
            </View>
            {this.state.storiesViewedBy.map(user => (
              <AvatarWithStory user={{ ...user }} />
            ))}
          </ScrollView>
        </Modal>
    );
  }
Viewing A User Story
Viewing a user story
The "Viewed By" Modal
Our viewed-by modal

And here’s the component for the progress bar at the top of a story:

TopBar.js

  // Setting current index of stories & number of stories to state 
  static getDerivedStateFromProps(nextProps, prevState) {
      return {
        currentIndex: nextProps.index,
        noOfStories: nextProps.totalStories
      };
  }

  componentDidMount() {
    this.updateNoOfProgress();
  }

  componentDidUpdate(prevProps, prevState) {
    // Checking if slide changed
    if (prevProps.index !== this.props.index) {
      // If yes, then clear interval
      if (this.interVal) clearInterval(this.interVal);
      // Reset and update progress bar
      this.updateNoOfProgress();
    }
  }

  // Resetting progress bar 
  updateNoOfProgress = () => {
      const duration = 60;
      this.setState({ noOfProgress: 0 });
      this.interval = setInterval(() => {
        const { noOfProgress } = this.state;
        // If progress bar is complete, then clear interval
        if (noOfProgress === 100) {
          clearInterval(this.interval);
        } else {
          // Otherwise, keep updating progress bar by 1
          this.setState(pre => ({ ...pre, noOfProgress: pre.noOfProgress + 1 }));
        }
      }, duration);
  };

  render() {
    const { currentIndex, noOfStories, noOfProgress } = this.state;

    return (
      <View style={styles.container}>
        {[...Array(noOfStories)].map((story, index) => (
          <View
            style={[
              styles.single,
              { width: Math.floor(width / noOfStories) - noOfStories }
            ]}
            key={index}
          >
            <ProgressBarAndroid
              styleAttr="Horizontal"
              indeterminate={false}
              progress={
                !(index >= currentIndex)
                  ? 1
                  : index === currentIndex
                  ? noOfProgress / 100
                  : 0
              }
              style={styles.bar}
              color="#fff"
            />
          </View>
        ))}
      </View>
    );
  }

const styles = StyleSheet.create({
  container: {
    marginTop: StatusBar.currentHeight,
    width,
    height: height * 0.03,
    paddingTop: height * 0.01,
    flexDirection: "row",
    justifyContent: "space-evenly"
  },
  bar: { transform: [{ scaleX: 1.0 }, { scaleY: 1 }], height: height * 0.01 },
  single: { marginLeft: 1 }
});

Demo and conclusion

Finally! We have achieved our third and last goal. Check out the demo below, and also check the GitHub repo for more details and working code. You can also directly run it via Expo.

Completed Story Demo

Thank you for reading the post! Hopefully it helped meet your needs!

Plug: , a DVR for web apps

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single-page apps.

.
Mohammad Kashif Sulaiman Mobile Apps Developer

Leave a Reply