INTEGRATING PUSH NOTIFICATIONS WITH Ruby on RAILS

I'm Guo Xiang and I work for 

There will BE CODE!

DEMO

Supported Browsers

Chrome Desktop/Mobile 50 and up

Firefox Desktop 44 and up

 

Prerequisites

HTTPS required

1. Serve a WEb MANIFEST

  • Simple JSON file
  • Gives the developer, the ability to control how your app appears to the user in the areas that they would expect to see apps
class MetadataController < ActionController::Base
  layout false

  def manifest
    manifest = {
      name: 'Chatty',
      short_name: "Chatty",
      display: 'standalone',
      orientation: 'portrait',
      icons: [
        {
          src: ActionController::Base.helpers.asset_path('pokego.jpg'),
          sizes: "144x144",
          type: "image/png"
        }
      ],
      gcm_sender_id: '439495341364'
    }

    render json: manifest.to_json
  end
end
Rails.application.routes.draw do
  # ...
  get "manifest.json" => "metadata#manifest", as: :manifest
end

config/routes.rb

views/layouts/application.html.erb

<!DOCTYPE html>
<html>
  <head>
    # ..
    <link rel="manifest" href="/manifest.json">
  </head>

  <body>
    # ..
  </body>
</html>

2. REgister a Service Worker

var isPushNotificationsSupported = function() {
  if (!(('serviceWorker' in navigator) &&
     (ServiceWorkerRegistration &&
     ('showNotification' in ServiceWorkerRegistration.prototype) &&
     ('PushManager' in window)))) {

    return false;
  }

  return true;
}

app/assets/javascripts/subscribe-push-notifications.js

var register = function() {
  if (!isPushNotificationsSupported()) return;

  navigator.serviceWorker.register('/push-service-worker.js').then(function() {
    if (Notification.permission === 'denied') return;

    navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
      serviceWorkerRegistration.pushManager.getSubscription().then(function(subscription) {
        if (subscription) {
          sendSubscriptionToServer(subscription);
          // Resync localStorage
          localStorage.setItem('chatty-push-notifications', 'subscribed');
        }
      }).catch(function(e) {
        console.log(e);
      });
    });
  });
}

app/assets/javascripts/subscribe-push-notifications.js

var sendSubscriptionToServer = function(subscription) {
  $.ajax({
    url: '/push_notifications/subscribe',
    type: 'POST',
    data: { subscription: subscription.toJSON() }
  });
}

app/assets/javascripts/subscribe-push-notifications.js

{
    "subscription" => { 
        "endpoint"=>"https://android.googleapis.com/gcm/send/cwn43boFHDQ:A \
                    PA91bHylQE1CqHd3KOAjWxZ6rY-rjouXRSg8xYeO2Q-WyK_84ew8LgT \
                    Hye8ESYWrv8gHwvv-7leDwBlaG2rimjrX6CsOs1rygwZhHZLg8PR66u \
                    reTzFkiWwZkmb8rSqryyyEHmodlkB", 
        "keys"=>{
            "p256dh"=>"BHalOZQxChWamOKKNYdOsHxjQILwNOLvcvMmm0F-d5dkiff7IIzB \
                       VbaF-IRefcFlDRjYI0eFDijCsHyBLj45YCc=", 
            "auth"=>"d6cZHc-8Fd7jSnCHZBW4CQ=="
        }
    }, 
    "controller"=>"push_notifications", 
    "action"=>"subscribe"
}

app/assets/javascripts/subscribe-push-notifications.js

'use strict';

function showNotification(title, body, icon, tag, url) {
  var notificationOptions = {
    body: body,
    icon: icon,
    data: { url: url },
    tag: tag
  }

  return self.registration.showNotification(title, notificationOptions);
}

self.addEventListener('push', function(event) {
  var payload = event.data.json();

  event.waitUntil(
    self.registration.getNotifications({ tag: payload.tag }).then(function(notifications) {
      if (notifications && notifications.length > 0) {
        notifications.forEach(function(notification) {
          notification.close();
        });
      }

      return showNotification(payload.title, payload.body, payload.icon, payload.tag, payload.url);
    })
  );
});

self.addEventListener('notificationclick', function(event) {
  // Android doesn't close the notification when you click on it
  // See: http://crbug.com/463146
  event.notification.close();
  var url = event.notification.data.url;

  // This looks to see if the current window is already open and
  // focuses if it is
  event.waitUntil(
    clients.matchAll({ type: "window" })
      .then(function(clientList) {
        clientList.forEach(function(client) {
          if (client.url === url && 'focus' in client) return client.focus();
        });

        if (clients.openWindow) return clients.openWindow(url);
      })
  );
});

app/assets/javascripts/push-service-worker.js

class ServiceWorkersController < ActionController::Base
  layout false

  def push
    pathname =
      if Rails.env.production?
        "#{Rails.application.assets_manifest.directory}/" \
        "#{Rails.application.assets_manifest.assets['push-service-worker.js']}"
      else
        Rails.application.assets.find_asset('push-service-worker.js').pathname
      end

    render file: pathname, content_type: Mime[:js]
  end
end

app/assets/javascripts/push-service-worker.js

config/routes.rb

Rails.application.routes.draw do
  # ...
  get "push-service-worker.js" => "service_workers#push"
end

3. Subscribing / Unsubscribing to Push notifications

var subscribe = function() {
  if (!isPushNotificationsSupported()) return;

  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    serviceWorkerRegistration.pushManager.subscribe({ userVisibleOnly: true })
      .then(function(subscription) {
        sendSubscriptionToServer(subscription);
        localStorage.setItem('chatty-push-notifications', 'subscribed');
      }).catch(function(e) {
        console.log(e);
      });
  });
}

app/assets/javascripts/subscribe-push-notifications.js

var unsubscribe = function() {
  if (!isPushNotificationsSupported()) return;

  navigator.serviceWorker.ready.then(function(serviceWorkerRegistration) {
    serviceWorkerRegistration.pushManager.getSubscription().then(function(subscription) {
      if (subscription) {
        subscription.unsubscribe().then(function(successful) {
          if (successful) {
            $.ajax({
              url: '/push_notifications/unsubscribe',
              type: 'POST',
              data: { subscription: subscription.toJSON() }
            });

            localStorage.setItem('chatty-push-notifications', '');
          }
        });
      }
    }).catch(function(e) {
      console.log(e)
    });
  });
}

app/assets/javascripts/subscribe-push-notifications.js

class PushNotificationsController < ApplicationController
  layout false

  def subscribe
    endpoint = push_params[:endpoint]

    if endpoint.start_with?(GCMPusher::ENDPOINT)
      GCMPusher.subscribe(current_user, push_params)
    elsif endpoint.start_with?(MozillaPusher::ENDPOINT)
      MozillaPusher.subscribe(current_user, push_params)
    end
  end

  # ...
end

app/controllers/push_notifications_controller.rb

class PushNotificationsController < ApplicationController
  layout false

  # ...

  def unsubscribe
    endpoint = push_params[:endpoint]

    if endpoint.start_with?(GCMPusher::ENDPOINT)
      GCMPusher.unsubscribe(current_user, push_params)
    elsif endpoint.start_with?(MozillaPusher::ENDPOINT)
      MozillaPusher.unsubscribe(current_user, push_params)
    end
  end

  # ...
end

app/controllers/push_notifications_controller.rb

class AddPushSubscriptionsToUser < ActiveRecord::Migration[5.0]
  def change
    add_column :users, :push_subscriptions, :json
  end
end
class BasePusher
  def self.key_prefix
    raise "Not implemented."
  end

  def self.push(user, payload)
    raise "Not implemented."
  end

  # ...
end

app/services/base_pusher.rb

class BasePusher
  # ...

  def self.subscriptions(user)
    user.push_subscriptions ||= {}
    user.push_subscriptions[key_prefix] ||= {}
    user.push_subscriptions[key_prefix]
  end

  # ...
end

app/services/base_pusher.rb

class BasePusher
  # ...

  def self.subscribe(user, subscription)
    unique_id = extract_unique_id(subscription)

    subscriptions(user)[unique_id] 
        = subscription.to_json

    user.save!
  end

  protected

  def self.extract_unique_id(subscription)
    subscription["endpoint"].split("/").last
  end
end

app/services/base_pusher.rb

class BasePusher
  # ...

  def self.unsubscribe(user, subscription)
    unique_id = extract_unique_id(subscription)
    subscriptions(user).delete(unique_id)
    user.save!
  end

  # ...
end

app/services/base_pusher.rb

4. Sending Push notifications

 gem 'webpush'
class GCMPusher < BasePusher
  ENDPOINT = 'https://android.googleapis.com/gcm/send'.freeze

  def self.key_prefix
    "google-cloud-messaging".freeze
  end

  # ...
end

app/services/gcm_pusher.rb

class GCMPusher < BasePusher
  # ...

  def self.push(user, payload)
    updated = false

    message = {
      title: payload[:title],
      body: payload[:content],
      icon: ActionController::Base.helpers.asset_path('pokego.jpg'),
      tag: "chatty",
      url: payload[:url]
    }

    subscriptions(user).each do |_, subscription|
      subscription = JSON.parse(subscription)

      begin
        Webpush.payload_send(
          endpoint: subscription["endpoint"],
          message: message.to_json,
          p256dh: subscription.dig("keys", "p256dh"),
          auth: subscription.dig("keys", "auth"),
          api_key: 'AIzaSyCqpw_NRuLfhJKh83I-Z2GsVoqQ3tih4pI'
        )
      rescue Webpush::InvalidSubscription
        # Delete the subscription from Redis
        updated = true
        subscriptions(user).delete(extract_unique_id(subscription))
      end
    end

    user.save! if updated
  end

  # ...
end

app/services/gcm_pusher.rb

class MozillaPusher < BasePusher
  ENDPOINT = 'https://updates.push.services.mozilla.com/push'.freeze

  def self.key_prefix
    "push-services-mozilla".freeze
  end

  def self.push(user, payload)
    updated = false

    message = {
      title: payload[:title],
      body: payload[:content],
      icon: ActionController::Base.helpers.asset_path('pokego.jpg'),
      tag: "chatty",
      url: payload[:url]
    }

    subscriptions(user).each do |_, subscription|
      subscription = JSON.parse(subscription)

      begin
        payload = Webpush::Encryption.encrypt(
          message.to_json,
          subscription.dig("keys", "p256dh"),
          subscription.dig("keys", "auth")
        )

        Webpush::Request.new(subscription["endpoint"], { payload: payload }).perform
      rescue Webpush::InvalidSubscription => e
        updated = true
        subscriptions(user).delete(extract_unique_id(subscription))
      end
    end

    user.save! if updated
  end
end

app/services/mozilla_pusher.rb

class SendPushNotificationsJob < ApplicationJob
  queue_as :default

  def perform(params)
    User.all.where.not(id: params[:user_id]).each do |user|
      [GCMPusher, MozillaPusher].each do |pusher|
        pusher.public_send(:push, user, params[:payload])
      end
    end
  end
end

app/job/send_push_notifications_job.rb

class MessagesController < ApplicationController

  def create
    message = Message.new(message_params)
    message.user = current_user

    if message.save
      ActionCable.server.broadcast 'messages',
        message: message.content,
        user: message.user.username

      SendPushNotificationsJob.perform_later({
        user_id: current_user.id,
        payload: {
          title: "#{message.user.username} Posted",
          content: message.content,
          url: "/chatrooms/#{Chatroom.find(message.chatroom_id).slug}"
        }
      })

      head :ok
    end
  end

  # ...
end

app/controller/messages_controller.rb

5. Summary

  1. Client registers a service worker and sends existing subscription to serve if any.
  2. User subscribes to push notifications and subscription is sent to server to be stored.
  3. Server extracts the unique id in the subscription endpoint and use it as the key for the subscription object.
  4. Server sends push notifications by POSTing to push messaging services endpoint.

Questions?

Push Notifications in Rails

By tgxworld

Push Notifications in Rails

  • 1,293