Written by Mohammad Kashif Sulaiman✏️
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>
Let’s start!
Now, we have to achieve three targets:
- Add your story/status
- List all of the user’s statuses
- 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>
}
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>
);
}
}
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>
);
}
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.
Thank you for reading the post! Hopefully it helped meet your needs!
Editor's note: Seeing something wrong with this post? You can find the correct version here.
Plug: LogRocket, 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.
Try it for free.
The post Mimic WhatsApp stories using React Native and Firestore appeared first on LogRocket Blog.
Top comments (0)