You will need Node, Yarn and React Native installed on your machine.
In this tutorial, you’ll learn how to make Chatkit messages available offline while a React Native app is running in the background.
Knowledge of React and React Native is required to follow this tutorial. Knowledge of Redux is helpful but not required.
The following package versions are used. If you encounter any issues in compiling the app, try to use the following:
You’ll need a Chatkit app instance with the test token provider enabled. If you don’t know the basics of using Chatkit yet, be sure to check out the official docs. This tutorial assumes that you at least know how to create, configure, and inspect a Chatkit app instance.
Lastly, you’ll need an ngrok account for exposing the server to the internet.
As mentioned in the previous section, we will be adding an offline functionality on top of an existing React Native chat app. So in the repo, I’ve added a starter branch which already contains the chat code. Go ahead and clone it and switch to the branch:
git clone https://github.com/anchetaWern/RNChatkitBackgroundSync cd RNChatkitBackgroundSync git checkout starter
Next, install and link the dependencies:
yarn react-native eject react-native link react-native-gesture-handler react-native link react-native-config react-native link react-native-background-timer
An extra step is required by React Native Config for Android. Add the following to the android/app/build.gradle
file:
apply from: "../../node_modules/react-native/react.gradle"// add these: apply from: project(':react-native-config').projectDir.getPath() + "/dotenv.gradle"
You also need to update your android/app/src/main/AndroidManifest.xml
file to include the permission for accessing the network state. This is required by the React Native Offline package so it has access to the network state:
<uses-permission android:name=“android.permission.ACCESS_NETWORK_STATE” />
Now we’re ready to start coding. First, we’ll update the route file to import and use the packages that we need for implementing offline functionality. Then we’ll add the reducers and action creators that are responsible for updating the store. The store will be automatically persisted using the Redux Persist package so we can actually implement the Redux store just like we normally do. Lastly, we’ll update the login and chat screen to use the action creators.
First, import the packages that we need. This includes React Native Offline for various offline utilities and components, Redux Saga for implementing the network event listener saga from React Native Offline, and Redux and React Redux for implementing the store, and Redux Persist for persisting the store in the local storage:
// update: Root.js
import { persistStore, persistReducer } from “redux-persist”;
import storage from “redux-persist/lib/storage”;
import { PersistGate } from “redux-persist/integration/react”;
import createSagaMiddleware from “redux-saga”;import { Provider } from "react-redux"; import { createStore, combineReducers, applyMiddleware } from "redux"; import { ReduxNetworkProvider, reducer as network, createNetworkMiddleware } from "react-native-offline";
Next, import the Chat reducer and the saga for watching when the device goes either offline or online:
import ChatReducer from ‘./src/reducers/ChatReducer’;
import { watcherSaga } from ‘./src/sagas’;const sagaMiddleware = createSagaMiddleware(); const networkMiddleware = createNetworkMiddleware();
Next, add the Redux Persist config. This allows us to specify the storage to use. In this case, we’re using the AsyncStorage implementation of Redux Persist. The key
for the root of the storage should be root
, while the key
for the Chat reducer is chat
. This should be the same as the key you provide to the Chat reducer when you call combineReducers
(we’ll get to that later):
const persistConfig = {
key: “root”,
storage
};const chatPersistConfig = { key: "chat", storage: storage }; const rootReducer = combineReducers({ chat: persistReducer(chatPersistConfig, ChatReducer), network }); const persistedReducer = persistReducer(persistConfig, rootReducer)
Next, persist the store and run the watcher saga:
const store = createStore(
persistedReducer,
applyMiddleware(networkMiddleware, sagaMiddleware)
);
let persistor = persistStore(store);sagaMiddleware.run(watcherSaga);
Lastly, wrap the AppContainer
with the <PersistGate>
component. This delays the rendering of the main app container until the persisted state is retrieved from local storage and saved to the Redux store. On the other hand, <ReduxNetworkProvider>
is the component exposed by React Native Offline for the purpose of passing down the network state as props to the children of the app container. This allows us to selectively render various components based on the network status (you’ll see this in action in the Login screen later):
const RootStack = createStackNavigator(
// …
);const AppContainer = createAppContainer(RootStack); // update this: class Router extends Component { render() { return ( <Provider store={store}> <PersistGate loading={null} persistor={persistor}> <ReduxNetworkProvider> <AppContainer /> </ReduxNetworkProvider> </PersistGate> </Provider> ); } } export default Router;
Now we move on to adding the action types. These are the type of actions that we will be dispatching from the Login and Chat screens to update the store:
// create: src/actions/types.js export const SET_USER = “set_user”; // for setting the username of the current user export const SET_FRIEND = “set_friend”; // for setting the friend’s username export const SET_ROOM = “set_room”; // for setting the object containing the name and ID of the Chatkit room export const PUT_MESSAGE = “put_message”; // for pushing a single message into the store export const SET_MESSAGES = “set_messages”; // for setting the messages in the store export const PUT_OLDER_MESSAGES = “put_older_messages”; // for prepending messages in the messages that are currently in the store
Next, create the action creators file. These are the functions that we will be dispatching when we want to update various parts of the store. Each function returns an object containing the type
of the action and the payload which will be passed by the caller:
// create: src/actions/index.js
import {
SET_USER,
SET_FRIEND,
SET_ROOM,
PUT_MESSAGE,
SET_MESSAGES,
PUT_OLDER_MESSAGES
} from “./types”;export const setUser = user => { return { type: SET_USER, user } }; export const setFriend = friend => { return { type: SET_FRIEND, friend } }; export const setRoom = room => { return { type: SET_ROOM, room } }; export const putMessage = message => { return { type: PUT_MESSAGE, message }; }; export const setMessages = messages => { return { type: SET_MESSAGES, messages }; }; export const putOlderMessages = messages => { return { type: PUT_OLDER_MESSAGES, messages }; };
Next, create the Chat Reducer. This is the one responsible for describing how the store will change based on the actions that it receives:
// create: src/reducers/ChatReducer.js
import {
SET_USER,
SET_FRIEND,
SET_ROOM,
PUT_MESSAGE,
SET_MESSAGES,
PUT_OLDER_MESSAGES
} from “…/actions/types”;const INITIAL_STATE = { user: null, friend: null, room: null, messages: [] }; export default (state = INITIAL_STATE, action) => { switch (action.type) { case SET_USER: return { ...state, user: action.user }; case SET_FRIEND: return { ...state, friend: action.friend }; case SET_ROOM: return { ...state, room: action.room }; case PUT_MESSAGE: const updated_messages = [action.message].concat(state.messages); return { ...state, messages: updated_messages }; case SET_MESSAGES: // initialization, refresh return { ...state, messages: action.messages }; case PUT_OLDER_MESSAGES: // load previous messages const current_messages = [...state.messages]; const older_messages = action.messages.reverse(); const with_old_messages = current_messages.concat(older_messages); return { ...state, messages: with_old_messages }; default: return state; } };
To consolidate things, we bring the Chat Reducer and the Network Reducer provided by React Native Offline together:
// create: src/reducers/index.js
import { combineReducers } from “redux”;
import ChatReducer from “./ChatReducer”;
import { reducer as network } from “react-native-offline”;export default combineReducers({ chat: ChatReducer, network });
Lastly, create the saga for watching the network (when it goes offline or online):
// create: src/sagas/index.js
import { networkSaga } from “react-native-offline”;
import { fork, all } from “redux-saga/effects”;export function* watcherSaga() { yield all([ fork(networkSaga, { timeout: 5000, // 5-second timeout for retrieving the network status checkConnectionInterval: 1000 // check network status every 1 second }) ]); }
As mentioned earlier, the chat functionality has already been laid out. All we have to do in each of the screens is to dispatch the action creators instead of setting data into the component’s state.
In the Login screen, start by adding the ActivityIndicator
:
// src/screens/Login.js import { View, Text, TextInput, TouchableOpacity, ActivityIndicator } from “react-native”; // add ActivityIndicator
Next, import the action creators:
import { connect } from ‘react-redux’; import { setUser, setFriend } from ‘…/actions’;
Once the component is mounted, we check if the user is online. If they’re not, then we automatically navigate them to the Chat screen. This is because we don’t really have the capability to authenticate them when they’re offline. So we just log in the last user who used the app:
componentDidMount() {
const { isConnected, user, friend, messages } = this.props;
if (user && !isConnected) {
this.props.navigation.navigate(“Chat”, {
user_id: user.id,
username: user.name,
friends_username: friend
});
}
}
Note: Since we’re now using Redux, all the general data used by the app is now passed down as a prop. Though this doesn’t happen automatically because we first have to connect the component by means of the connect
method provided by React Redux.
Next, show a loading animation if the user is offline. We only show the login form if the user is online. This means that the login screen will simply show an infinite loading animation if a user didn’t log in previously:
render() {
const { isConnected, user, friend } = this.props;
const { username, friends_username } = this.state;return ( <View style={styles.wrapper}> { !isConnected && <ActivityIndicator size="small" color="#0064e1" style={styles.loader} /> } { isConnected && <View style={styles.container}> <View style={styles.main}> <View style={styles.fieldContainer}> <Text style={styles.label}>Enter your username</Text> <TextInput style={styles.textInput} onChangeText={username => this.setState({ username })} value={username} /> </View> <View style={styles.fieldContainer}> <Text style={styles.label}>Enter friend's username</Text> <TextInput style={styles.textInput} onChangeText={friends_username => this.setState({ friends_username })} value={friends_username} /> </View> {!this.state.is_loading && ( <TouchableOpacity onPress={this.enterChat}> <View style={styles.button}> <Text style={styles.buttonText}>Login</Text> </View> </TouchableOpacity> )} {this.state.is_loading && ( <Text style={styles.loadingText}>Loading...</Text> )} </View> </View> } </View> ); }
Next, update the enterChat
method so that it saves the details of the current user as well as the username of their friend to the store. This is the one that’s filling the value for user
when the component is mounted:
enterChat = async () => {
// …
const user_id = stringHash(username).toString();
const { setUser, setFriend } = this.props; // add thisthis.setState({ is_loading: true }); if (username && friends_username) { // add these: setUser({ id: user_id, name: username }); setFriend(friends_username); // ... } }
Lastly, add the mapStateToProps
and mapDispatchToProps
. The former allows us to extract specific data from the store and make it available as props. While the latter allows us to create functions that are used for dispatching action creators and make it available as props:
const mapStateToProps = ({ network, chat }) => {
const { isConnected } = network;
const { user, friend, messages } = chat;
return {
isConnected,
user,
friend,
messages
};
};const mapDispatchToProps = dispatch => { return { setUser: user => { dispatch(setUser(user)); }, setFriend: friend => { dispatch(setFriend(friend)); } }; }; // connect the component to the store export default connect( mapStateToProps, mapDispatchToProps )(Login);
Let’s proceed to updating the Chat screen. Start by importing the additional modules that we need, as well as the action creators:
// src/screens/Chat.js
// …
import { View, ActivityIndicator, AppState } from “react-native”; // add AppState
import Config from “react-native-config”;// add these import { connect } from "react-redux"; import BackgroundTimer from "react-native-background-timer"; // for periodically executing specific code import { setRoom, setMessages, putMessage, putOlderMessages } from '../actions'; // ...
Next, we initialize the value of appstate. Here, we’re using React Native’s built-in module to check the app state. The app state can either be active, background, or inactive. We make use of appstate
to determine whether the app is in the background or not at any given time:
state = {
// …
show_load_earlier: false,
app_state: AppState.currentState
};
Once the component is mounted, we check if the user is offline and immediately initialize the Chat screen if they are. This is because, by default, the Chat screen displays an animated loader until Chatkit has been initialized. We can’t really initialize Chatkit when the user is offline, so we immediately set is_initialized
to true
so that the chat UI will be displayed. Aside from that, we also add an event listener to the app state. We need to do this because the value of AppState.currentState
is only initialized once so it doesn’t really reflect the current app state:
componentDidMount() {
const { isConnected, setMessages, putMessage, messages } = this.props;
AppState.addEventListener(‘change’, this._handleAppStateChange);if (isConnected) { // wrap this.enterChat with this condition this.enterChat(); } // next: add code for background sync // add these: if (!isConnected) { this.setState({ is_initialized: true }); } }
Next, we add the code for running a task in the background. This uses the React Native Background Timer package to periodically run specific code even if the app goes into the background. Note that the function that we supply to the runBackgroundTimer
method is actually executed even when the app is in the foreground. This is where checking for the current app state comes in. If it’s not active
, then that’s the time we execute our messages syncing code. In the server, later on, we’ll add a new route called /messages
which is responsible for returning an array of messages that were added after the initial_id
supplied:
BackgroundTimer.runBackgroundTimer(() => {
const { app_state } = this.state;if (isConnected && app_state !== 'active') { // fetch messages from the server console.log('app went to background, now getting messages from the server...'); const latest_message_id = Math.max( ...messages.map(m => parseInt(m._id)) ); axios.get(`${CHAT_SERVER}/messages`, { params: { room_id: this.room_id, initial_id: latest_message_id } }) .then((response) => { // next: add code for updating the store with the new messages }) .catch((err) => { console.log("error occurred: ", err); }); } }, 60000);
Here’s the code for putting the new messages into the store. Note that the individual message objects are different from the ones that you’re getting from Chatkit’s frontend API. I’ve also added a sample message object below:
const { messages } = response.data;
messages.reverse().forEach((msg) => {
/*
{
id:101230436,
user_id:‘193417020’,
room_id:‘31068818’,
parts:[
{
content:‘hello!’,
type:‘text/plain’
}
],
created_at:‘2019-04-08T08:03:33Z’,
updated_at:‘2019-04-08T08:03:33Z’
}
*/const text = msg.parts.find(part => part.type === 'text/plain').content; const message = { _id: msg.id, text: text, createdAt: msg.created_at, user:{ _id: msg.user_id, avatar: "https://png.pngtree.com/svg/20170602/0db185fb9c.png" } } putMessage(message); });
Next, add the code for listening for when the app state changes. Aside from updating the state, we also need to disconnect the current user from Chatkit if the app goes to the background, and connect them when the app goes in the foreground. We need to do this to ensure that the existing Chatkit connections don’t interfere with our code for syncing the messages in the background:
_handleAppStateChange = (nextAppState) => {
if (nextAppState !== ‘active’ && this.currentUser) {
this.currentUser.disconnect();
} else if (nextAppState === ‘active’) {
this.enterChat();
}this.setState({ app_state: nextAppState }); };
Next, update the enterChat
method to set Chatkit’s logger to use console.log
for every type of log. This helps us prevent React Native’s “Red Screen of Death” from showing when Chatkit couldn’t sync messages while the user is offline. From here, we also dispatch the actions for setting the current room and setting the messages so that the store is updated:
enterChat = async () => {const { setRoom, setMessages } = this.props; try { if (!this.chatManager) { this.chatManager = new ChatManager({ instanceLocator: CHATKIT_INSTANCE_LOCATOR_ID, userId: this.user_id, tokenProvider: new TokenProvider({ url: CHATKIT_TOKEN_PROVIDER_ENDPOINT }), // add these: logger: { verbose: console.log, debug: console.log, info: console.log, warn: console.log, error: console.log, } }); // ... this.room_id = room.id.toString(); // add this setRoom({ id: this.room_id, name: this.room_name }); // ... } setMessages([]); // add this // ... } catch (err) { console.log("error with chat manager: ", err); } }
Note:setMessages
will reset themessages
array in the store. This means any of the old messages that were previously loaded via Chatkit’sfetchMessages
method will also be deleted. If you want to retain old messages, you will need to update the Chat reducer.
Next, update the render
method to load the messages
from props instead of from the state. The rest of the code remains intact:
render() {
const { is_initialized, show_load_earlier } = this.state;
const { messages } = this.props; // add thisreturn ( <View style={styles.container}> {(!is_initialized) && ( <ActivityIndicator // ... /> )} {is_initialized && ( <GiftedChat // ... /> )} </View> ); }
Next, update the onSend
method to check if the user is online. The user should only be able to send a message if they are online:
onSend([message]) {
const { isConnected } = this.props;if (isConnected) { // ... } }
Next, update the onReceive
method to dispatch the putMessage
action when a new message is received:
onReceive = async (data) => {
const { messages, putMessage } = this.props;
const { message } = await this.getMessage(data);putMessage(message); // ... }
Next, update the method for loading older messages so it dispatches the putOlderMessages
action when all the older messages are added to the earlier_messages
array. Also, make sure to get the messages
from props instead of from the state:
loadEarlierMessages = async () => {const { putOlderMessages, isConnected, messages } = this.props; // add this if (isConnected) { // ... const earliest_message_id = Math.min( ...messages.map(m => parseInt(m._id)) // update this ); try { let messages = await this.currentUser.fetchMessages({ // ... }); if (!messages.length) { // ... } let earlier_messages = []; await this.asyncForEach(messages, async (msg) => { let { message } = await this.getMessage(msg); earlier_messages.push(message); }); putOlderMessages(earlier_messages); // add this } catch (err) { // ... } } await this.setState({ is_loading: false }); }
Lastly, connect the component to the store:
const mapStateToProps = ({ network, chat }) => {
const { isConnected } = network;
const { user, messages } = chat;
return {
isConnected,
user,
messages
};
}const mapDispatchToProps = dispatch => { return { setRoom: room => { dispatch(setRoom(room)); }, setMessages: messages => { dispatch(setMessages(messages)); }, putMessage: message => { dispatch(putMessage(message)); }, putOlderMessages: older_messages => { dispatch(putOlderMessages(older_messages)); } }; } export default connect( mapStateToProps, mapDispatchToProps )(Chat);
The final thing we need to do to implement background sync is to add the /messages
route to the server. As mentioned earlier, this is responsible for returning the messages that were added after the initial_id
passed into the request:
app.get(“/messages”, async (req, res) => {
const { room_id, initial_id } = req.query;
try {
const messages = await chatkit.fetchMultipartMessages({
roomId: room_id,
limit: 10,
initialId: initial_id // only fetch messages after this message ID
});res.send({ messages }); } catch (err) { console.log("error fetching messages: ", err); } });
Now we’re ready to run the app. But before doing so, make sure to update the .env
and server/.env
files with your Chatkit credentials.
Once that’s done, you can now install the dependencies and run the app:
cd server
yarn
yarn start
./ngrok http 5000
Lastly, update your app/screens/Login.js
and app/screens/Chat.js
file with your ngrok HTTPS URL and then run the app:
react-native run-android react-native run-ios
To test the functionality we just implemented, log in to the app and then go to your home screen (or open another app) so that the React Native app goes to the background. Once the app is in the background, you can send messages to the user you logged in via the Chatkit console or running the app on another device. Those messages should sync to the app and saved in device’s the local storage. This allows the user to view those messages when they pull the app back to the foreground at a later time even when they’re offline. This is as opposed to simply pulling the messages when the app goes to the foreground, because if they are offline at the time the app goes to the foreground then there would be no new messages available to read.
That’s it! In this tutorial, you learned how to make Chatkit messages available offline while a React Native app is running in the background.
The app we created is fairly limited in its message syncing features though. Because as soon as the user closes the app or the device goes into idle state, the syncing is also effectively stopped.
As next steps, you can try implementing the following so the app continually syncs messages even if the device goes idle or the user closes the app:
#javascript #node-js #reactjs #ios #chatbot