🎉 New release for Pusher Chatkit - Webhooks! Extend your in-app chat functionality
Hide
Products
chatkit_full-logo

Extensible API for in-app chat

channels_full-logo

Build scalable realtime features

beams_full-logo

Programmatic push notifications

Developers

Docs

Read the docs to learn how to use our products

Tutorials

Explore our tutorials to build apps with Pusher products

Support

Reach out to our support team for help and advice

Sign in
Sign up

Syncing Chatkit messages in the background in React Native

  • Wern Ancheta
May 15th, 2019
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.

Prerequisites

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:

  • Node 11.2
  • Yarn 1.13
  • React Native 0.59

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.

Bootstrapping the app

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

Adding offline functionality

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.

Route file

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;

Action types

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

Action creator

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

ChatReducer

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

Login screen

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 this

      this.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);

Chat screen

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 app_state. 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 app_state 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 the messages array in the store. This means any of the old messages that were previously loaded via Chatkit’s fetchMessages 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 this

      return (
        <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);

Update the server

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

Running the app

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.

Conclusion and next steps

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:

  • Push notifications - try implementing Push notifications for every message that’s received, but not when the user is currently using the app. You’ll need a server to implement this. First, you need to find a way to determine whether a specific user is currently using the app. Next, you can use Chatkit Webhooks to have your server listen to messages as they are sent. From there, you can send Push notifications to the receiver if the app isn’t currently in the foreground. You can use Pusher Beams or Firebase Cloud Messaging for that.
  • Background tasks - another option is to run a background task in the app. This will be responsible for syncing the messages at a specific interval. Reid Mayo has written a tutorial on this: Easy OS Background Tasks in React Native. There are also various packages that implement background jobs in one way or another: React Native Background Job, React Native Background Geolocation. Those might help you figure out how to continually run the app’s code even if the app is closed. But as all background jobs go, they’re not really friendly on the battery.

You can view the code on this GitHub repo.

Clone the project repository
  • Android
  • Chat
  • iOS
  • JavaScript
  • Node.js
  • React Native
  • Chatkit

Products

  • Channels
  • Chatkit
  • Beams

© 2019 Pusher Ltd. All rights reserved.

Pusher Limited is a company registered in England and Wales (No. 07489873) whose registered office is at 160 Old Street, London, EC1V 9BW.