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
- A service worker is a script that is run by your browser in the background, separate from a web page, opening the door to features which don't need a web page or user interaction. http://www.html5rocks.com/en/tutorials/service-worker/introduction/
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
- Client registers a service worker and sends existing subscription to serve if any.
- User subscribes to push notifications and subscription is sent to server to be stored.
- Server extracts the unique id in the subscription endpoint and use it as the key for the subscription object.
- Server sends push notifications by POSTing to push messaging services endpoint.
Questions?
Push Notifications in Rails
By tgxworld
Push Notifications in Rails
- 1,350