Send push notification an expo app from meteor.js

Catch up

While this post is self complete on it’s own in terms of demonstrating the main subject matter, we will be starting from an existing codebase with a significant amount of code and some presumption that the reader is familiar with that codebase and the tools used there.

In the previous post in this series, we built a group chat app with meteor on the server-side and expo on the client. If you haven’t followed that post, you will need to clone the repository and start from the end product of the previous post. You will need to run these commands to get that code:

git clone git@github.com:foysalit/textr-app-blog.git textr-app
cd text-app
git checkout -b push-notification 94fe59832200919431257066fb29091def79492e

It will create a new directory named textr-app and then create a new branch named push-notification where the code will be exactly what we had at the end of the last post.

Feature Spec

In our previous post, we built a group chat app where all members can send messages to everyone in the group chat app. Now in this post, we will implement push notification in our app so that once a new message is sent to the group, everyone in the group (except the sender of the message) will be notified.

Expo setup

If you’ve ever worked with push notifications, you know how complex and time consuming it can be to set up on any stack. Which is why there are so many businesses/SaaS out there built only to make this simpler. Coming from that experience, I was blown away at how easy expo makes it. We can get started by dropping in a package on our expo app:

cd text-rn
expo install expo-notifications

One limitation with push notification is that we have to run the app on an actual device in order for it to work and running on an emulator/simulator won’t work. So please connect a physical device to your machine and run the textr app on the device. If you’re not sure how to do that, follow this doc to find out.

One major thing about push notification is that they can be quite annoying, right? Especially if they’re coming from a group chat app. Who has time to read through the bickering of 3 of your drunk friends in a group chat during a pandemic, am I right? So, we will start with the strategy to turn the notification on/off.

Let’s first create a component for controlling push notification preference which can also isolate the notification related code. Create a new file notification-setting.tsx in the root of the textr-rn directory and put the following code in it:

import React from "react";
import Constants from 'expo-constants';
import {Alert, Platform} from "react-native";
import * as Notifications from 'expo-notifications';
import {Button, Div, Icon} from "react-native-magnus";
import Meteor, {withTracker} from '@meteorrn/core';

Notifications.setNotificationHandler({
    handleNotification: async () => ({
        shouldShowAlert: true,
        shouldPlaySound: true,
        shouldSetBadge: false,
    }),
});

const registerForPushNotification = async () => {
    let token;
    if (Constants.isDevice) {
        const { status: existingStatus } = await Notifications.getPermissionsAsync();
        let finalStatus = existingStatus;
        if (existingStatus !== 'granted') {
            const { status } = await Notifications.requestPermissionsAsync();
            finalStatus = status;
        }
        if (finalStatus !== 'granted') {
            Alert.alert('Something went wrong!', 'Failed to get push token for push notification!');
            return;
        }
        token = (await Notifications.getExpoPushTokenAsync()).data;
        Meteor.call('messages.notifications.register', token, (err: any) => {
            Alert.alert(
                err ? 'Something went wrong!' : 'Push notification enabled',
                err ? err.message : 'Press the notification icon any time to disable push notification'
            );
        });
    } else {
        Alert.alert('Something went wrong!', 'Must use physical device for Push Notifications');
    }

    if (Platform.OS === 'android') {
        await Notifications.setNotificationChannelAsync('default', {
            name: 'default',
            importance: Notifications.AndroidImportance.MAX,
            vibrationPattern: [0, 250, 250, 250],
            lightColor: '#FF231F7C',
        });
    }

    return token;
}

const enablePushNotification = () => {
    Alert.alert(
        "Enable Notification",
        "You will receive push notification for every new message in group chat. Are you sure you want to enable push notification?",
        [
            {
                text: "Cancel",
                onPress: () => null,
                style: "cancel"
            },
            { text: "Yes, Enable", onPress: () => registerForPushNotification() }
        ],
        { cancelable: false }
    );
};

const disablePushNotification = () => {
    Alert.alert(
        "Disable Notification",
        "You will stop receiving push notification from textr. Are you sure you want to disable push notification?",
        [
            {
                text: "Cancel",
                onPress: () => null,
                style: "cancel"
            },
            {
                text: "Yes, Disable",
                onPress: () => Meteor.call('messages.notifications.disable', (err) => {
                    Alert.alert(
                        err ? 'Something went wrong!' : 'Push notification disabled',
                        err ? err.message : 'Press the notification icon any time to re-enable push notification'
                    );
                })
            }
        ],
        { cancelable: false }
    );
};

const NotificationSetting = ({hasEnabledPushNotification = false}) => {
  return (
      <Div row justifyContent="flex-end">
          <Button
              bg="white"
              borderless
              h={40}
              w={40}
              rounded="circle"
              alignSelf="center"
              onPress={hasEnabledPushNotification ? disablePushNotification : enablePushNotification}
          >
              <Icon
                  rounded="circle"
                  name={hasEnabledPushNotification ? "ios-notifications-off" : "ios-notifications"}
                  fontSize="4xl"
                  fontFamily="Ionicons"
              />
          </Button>
      </Div>
  );
};


export default withTracker(() => {
    const user = Meteor.user();
    return {hasEnabledPushNotification: user?.profile?.enablePushNotification};
})(NotificationSetting);

There are a few parts to this. First, the registerForPushNotification function which simply checks a few permission and device related things before calling the Notifications.getExpoPushTokenAsync() method. The method returns an object with the property data which contains a string token and that token is what we need for sending push notification to this particular device from anywhere. Since we’re using meteor on the server, we need to keep this token stored on our database through the server. So naturally, we call a meteor method and send the token as param Meteor.call('messages.notifications.register', token...) We will look into the method later when we get to the server side of things.

Now in the react component NotificationSetting we are simply rendering a notification control button with an icon inside. The icon represents if the user has turned notification off or on depending on the hasEnabledPushNotification prop. Also, depending on the same prop, it will trigger either disablePushNotification or enablePushNotification function. Both of them will trigger a confirmation prompt first before performing any action.

Once the user accepts the prompt from enablePushNotification function, we trigger the registerPushNotification function to get the token and store it on the server. When the user accepts the prompt from disablePushNotification, we simply call a meteor method 'messages.notifications.disable'. Both of these method calls manipulate the prop hasEnabledPushNotification and due to the real time nature of meteor, the component will re-render with the updated value of the prop after the method calls succeed.

Which brings us to the withTracker HOC and if you look inside of it, you will see that it sets the value of hasEnabledPushNotification prop based on the logged in user’s profile.enablePushNotification property. So you can already guess that that’s what we’ll be modifying in the server method calls.

Now, before we start looking into the server side, the final thing we need on the client is to throw this new component into our chat page. Simply add it above the GiftedChat component in our chat-page.tsx file:

            <NotificationSetting />
            <GiftedChat
                user={user}
                messages={messages}

Meteor methods

On the meteor side of things, we start by opening the file at textr-api/imports/api/messages/server/methods.ts which already contains the method 'messages.add' which we will be modifying soon but let’s add our 2 new methods first:

    'messages.notifications.disable'() {
        check(this.userId, String);

        Meteor.users.update(this.userId, {
            $unset: { profile: 1 }
        });
    },
    'messages.notifications.register'(pushNotificationToken: string) {
        check(pushNotificationToken, String);
        check(this.userId, String);

        Meteor.users.update(this.userId, {
            $set: {
                profile: {pushNotificationToken, enablePushNotification: true}
            }
        });
    }

The disable method simply removes the entire profile object from the user entry of the currently logged in user. Before we run such code, of course, we need to make sure the user is logged in and check(this.userId, String); does that for us.

The register method expects the expo notification token string as input and stores the string in the profile object. Along with that, it sets enablePushNotification to true and if you remember, this is the property that determines on the client if the user has notification enabled or disabled.

Now the reason why we chose this magical profile object insite users collection is because this gets synchronized between server and client in real time without any additional supervision. Meteor takes care of all of that for us.

All of this simply controls the user’s notification preference and we still haven’t set up the actual notification sending mechanism.

Expo has a very simple rest api endpoint available for sending push notification which means we simply need to make an http request from our server when we want to send a notification to a device. To make http requests easily, we will be using the node-fetch package so let’s get that installed in our meteor app by running:

cd textr-api
meteor npm install node-fetch
meteor

Then paste in the following function inside our methods file:

function sendPushNotification(to: string[], sender: string | undefined, body: string) {
    const message = {
        to,
        body,
        sound: 'default',
        title: `New textr from ${sender}`,
    };

    return fetch('https://exp.host/--/api/v2/push/send', {
        method: 'POST',
        headers: {
            Accept: 'application/json',
            'Accept-encoding': 'gzip, deflate',
            'Content-Type': 'application/json',
        },
        body: JSON.stringify(message),
    });
}

It accepts an array of token strings that will determine the devices that will receive a notification and the message sender’s name as string which we will use to build the title of the notification. It also accepts a string body and in our case, we will send the entire new message as the body.

Internally, we are making a POST request to expo’s push notification service after building the message object with the necessary properties.

Now the last bit needed is to call this helper function to actually trigger the push notification when a new message is sent to the group and of course the right place to do that is inside the message.add method Adjust method to look like below:

    'messages.add'(text: string) {
        check(this.userId, String);
        const messageId = MessagesCollection.insert({
            text,
            userId: this.userId,
            createdAt: new Date(),
        });

        const sender = Meteor.users.findOne(this.userId)?.username;
        const notificationReceipients = Meteor.users.find({
            _id: {$ne: this.userId},
            'profile.enablePushNotification': true,
        }).fetch().map(({profile}) => profile.pushNotificationToken);
        sendPushNotification(notificationReceipients, sender, text);
        return messageId;
    }

Up until the MessagesCollection.insert method, everything remains the same. However, after sending the message, we will now query the database to the expo token of ALL users who have notification enabled, except for the sender of the message. Then we are just passing the tokens to the sendPushNotification function with the sender’s username and the actual text message.

That’s all we need! Now, go ahead and test it out using two devices. If you’d like to learn more about how expo’s push notification api works, their doc is the best place to get started: https://docs.expo.io/push-notifications/sending-notifications/

Published 13 Mar 2021

I write a lot of code at my day job, side hustles and for fun.
Foysal Ahamed on Twitter