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.
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>
users
→ <userId>
→ stories
→ <storyId>
→ <storyData>
Now, we have to achieve three targets:
So let’s start with the first point!
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.
_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> }
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.
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.
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> ); } }
Congrats! We have just hit our second target. Now let’s move on to the last target.
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.
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> ); }
And here’s the component for the progress bar at the top of a story:
// 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 } });
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.
Thank you for reading the post! Hopefully it helped meet your needs!
LogRocket is a React Native monitoring solution that helps you reproduce issues instantly, prioritize bugs, and understand performance in your React Native apps.
LogRocket also helps you increase conversion rates and product usage by showing you exactly how users are interacting with your app. LogRocket's product analytics features surface the reasons why users don't complete a particular flow or don't adopt a new feature.
Start proactively monitoring your React Native apps — try LogRocket for free.
Would you be interested in joining LogRocket's developer community?
Join LogRocket’s Content Advisory Board. You’ll help inform the type of content we create and get access to exclusive meetups, social accreditation, and swag.
Sign up nowCompare Prisma and Drizzle ORMs to learn their differences, strengths, and weaknesses for data access and migrations.
It’s easy for devs to default to JavaScript to fix every problem. Let’s use the RoLP to find simpler alternatives with HTML and CSS.
Learn how to manage memory leaks in Rust, avoid unsafe behavior, and use tools like weak references to ensure efficient programs.
Bypass anti-bot measures in Node.js with curl-impersonate. Learn how it mimics browsers to overcome bot detection for web scraping.
One Reply to "Mimic WhatsApp stories using React Native and Firestore"
Awesome! One question – from this example how would you go about showing stories from only your friends?