Add browser push notifications (attempt) (#412)

This commit is contained in:
Ben Halpern 2018-06-09 17:33:58 -04:00 committed by GitHub
parent bf56ad5cb4
commit 7bc3e60283
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 240 additions and 13 deletions

View file

@ -89,6 +89,7 @@ gem "twitter", "~> 6.2"
gem "uglifier", "~> 3.2"
gem "validate_url", "~> 1.0"
gem "webpacker", "~> 3.2"
gem 'webpush', "~> 0.3"
group :development do

View file

@ -474,6 +474,7 @@ GEM
hashdiff (0.3.7)
hashie (3.5.7)
heapy (0.1.3)
hkdf (0.3.0)
html_truncator (0.4.2)
nokogiri (~> 1.5)
http (3.0.0)
@ -885,6 +886,9 @@ GEM
activesupport (>= 4.2)
rack-proxy (>= 0.6.1)
railties (>= 4.2)
webpush (0.3.2)
hkdf (~> 0.2)
jwt
websocket-driver (0.6.5)
websocket-extensions (>= 0.1.0)
websocket-extensions (0.1.3)
@ -1023,6 +1027,7 @@ DEPENDENCIES
web-console (~> 3.5)
webmock (~> 3.3)
webpacker (~> 3.2)
webpush (~> 0.3)
RUBY VERSION
ruby 2.5.0p0

View file

@ -1,4 +1,4 @@
var CACHE_VERSION = 'v2.4.8';
var CACHE_VERSION = 'v2.5.0';
var CACHE_NAME = CACHE_VERSION + ':sw-cache::';
var REQUESTS_LIMIT = 70;
@ -56,6 +56,7 @@ function onFetch(event) {
)
);
function requestBlackListed(request){
var url = new URL(request.url);
var test = (event.request.method !== 'GET' ||
@ -112,6 +113,22 @@ function onFetch(event) {
}
};
function onPush(event) {
var title = "👋 New Message";
var body; body = event.data.text()
var tag = "push-simple-demo-notification-tag";
var icon = '<%= image_path "devlogo-pwa-128.png" %>';
event.waitUntil(
self.registration.showNotification(title, {
body: body,
icon: icon,
tag: tag
})
);
};
self.addEventListener('install', onInstall);
self.addEventListener('activate', onActivate);
self.addEventListener('fetch', onFetch);
self.addEventListener("push", onPush);

View file

@ -17,14 +17,55 @@
height: inherit;
}
.chat__notificationsbutton{
border: 0px;
font-size: 12px;
padding: 15px 0px;
width: 100%;
color: darken($green, 30%);
font-weight: bold;
width: 95%;
border-radius: 3px;
border: 1px solid darken($green, 30%);
box-shadow: 3px 3px 0px darken($green, 30%);
background: lighten($green,30%);
&:hover{
background: lighten($green, 20%);
}
}
.chat__channels {
width: 80px;
height: inherit;
position: relative;
@media screen and ( min-width: 550px ){
width: 160px;
}
}
.chat_chatconfig{
position: absolute;
bottom: 0px;
left: 0px;
right: 6px;
font-size:12px;
padding: 3px 12px;
display: inline-block;
border-radius: 3px;
background: $lightest-gray;
border: 1px solid $light-medium-gray;
font-weight: bold;
cursor: default;
}
.chat_chatconfig--on{
color: $green;
}
.chat_chatconfig--off{
color: $red;
}
.chat__channels--hidden {
display: none;
}
@ -158,6 +199,8 @@
.chatchannels__channelslist {
padding: 10px 0;
flex-grow: 1;
overflow: scroll;
padding-bottom: 25px;
}
.chatchannels__misc {

View file

@ -80,6 +80,7 @@ class ChatChannelsController < ApplicationController
end
def render_channels_html
return unless current_user
@chat_channels = current_user.chat_channels.
order("last_message_at DESC").
includes(:chat_channel_memberships)

View file

@ -11,6 +11,7 @@ class MessagesController < ApplicationController
begin
message_json = create_pusher_payload(@message)
Pusher.trigger(@message.chat_channel.pusher_channels, "message-created", message_json)
@message.delay.send_push
success = true
rescue Pusher::Error => e
logger.info "PUSHER ERROR: #{e.message}"

View file

@ -0,0 +1,13 @@
class PushNotificationSubscriptionsController < ApplicationController
def create
@subscription = PushNotificationSubscription.where(endpoint: params[:subscription][:endpoint]).
first_or_create(
auth_key: params[:subscription][:keys][:auth],
p256dh_key: params[:subscription][:keys][:p256dh],
endpoint: params[:subscription][:endpoint],
user_id: current_user.id,
notification_type: "browser",
)
render json: { status: "success", endpoint: @subscription.endpoint }, status: 201
end
end

View file

@ -70,3 +70,22 @@ export function conductModeration(
.then(successCb)
.catch(failureCb);
}
export function sendKeys(subscription, successCb, failureCb) {
fetch(`/push_notification_subscriptions`, {
method: 'POST',
headers: {
Accept: 'application/json',
'X-CSRF-Token': window.csrfToken,
'Content-Type': 'application/json',
},
body: JSON.stringify({
subscription: subscription
}),
credentials: 'same-origin',
})
.then(response => response.json())
.then(successCb)
.catch(failureCb);
}

View file

@ -1,7 +1,7 @@
import { h, Component } from 'preact';
import PropTypes from 'prop-types';
import { conductModeration, getAllMessages, sendMessage, sendOpen } from './actions';
import { hideMessages, scrollToBottom, setupObserver } from './util';
import { hideMessages, scrollToBottom, setupObserver, setupNotifications, getNotificationState } from './util';
import Alert from './alert';
import Channels from './channels';
import Compose from './compose';
@ -30,6 +30,7 @@ export default class Chat extends Component {
activeChannelId: chatOptions.activeChannelId,
showChannelsList: chatOptions.showChannelsList,
showTimestamp: chatOptions.showTimestamp,
notificationsPermission: null
};
}
@ -59,6 +60,7 @@ export default class Chat extends Component {
this.handleChannelOpenSuccess,
null,
);
this.setState({notificationsPermission: getNotificationState()});
}
componentDidUpdate() {
@ -179,7 +181,7 @@ export default class Chat extends Component {
scrolled: false,
showAlert: false,
});
window.history.replaceState(null, null, "/💌/"+e.target.dataset.channelSlug);
window.history.replaceState(null, null, "/gether/"+e.target.dataset.channelSlug);
document.getElementById("messageform").focus();
if (window.ga && ga.create) {
ga('send', 'pageview', location.pathname + location.search);
@ -206,6 +208,16 @@ export default class Chat extends Component {
}
};
triggerNotificationRequest = e => {
const context = this;
Notification.requestPermission(function (permission) {
if (permission === "granted") {
context.setState({notificationsPermission: "granted"});
setupNotifications();
}
});
}
handleChannelOpenSuccess = response => {
const newChannelsObj = this.state.chatChannels.map(channel => {
if (parseInt(response.channel) === channel["id"]){
@ -237,13 +249,25 @@ export default class Chat extends Component {
renderChatChannels = () => {
if (this.state.showChannelsList) {
const notificationsPermission = this.state.notificationsPermission;
let notificationsButton = "";
let notificationsState = "";
if (notificationsPermission === "waiting-permission") {
notificationsButton = <div><button class="chat__notificationsbutton " onClick={this.triggerNotificationRequest}>Turn on Notifications</button></div>;
} else if (notificationsPermission === "granted") {
notificationsState = <div class="chat_chatconfig chat_chatconfig--on">Notificatins On</div>
} else if (notificationsPermission === "denied") {
notificationsState = <div class="chat_chatconfig chat_chatconfig--off">Notificatins Off</div>
}
return (
<div className="chat__channels">
{notificationsButton}
<Channels
activeChannelId={this.state.activeChannelId}
chatChannels={this.state.chatChannels}
handleSwitchChannel={this.handleSwitchChannel}
/>
{notificationsState}
</div>
);
}

View file

@ -1,4 +1,6 @@
import 'intersection-observer';
import { sendKeys } from './actions';
export function getUserDataAndCsrfToken() {
const promise = new Promise((resolve, reject) => {
@ -73,3 +75,35 @@ export function adjustTimestamp(timestamp) {
time = new Intl.DateTimeFormat('en-US', options).format(time);
return time;
}
export function setupNotifications() {
navigator.serviceWorker.ready.then((serviceWorkerRegistration) => {
serviceWorkerRegistration.pushManager.getSubscription()
.then(function(subscription) {
if (subscription) {
return subscription;
}
return serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: window.vapidPublicKey
});
}).then(function(subscription) {
sendKeys(subscription.toJSON(), null, null)
});
});
}
export function getNotificationState() {
// Let's check if the browser supports notifications
if (!("Notification" in window)) {
return "not-supported"
} else if (Notification.permission === "granted") {
setupNotifications();
return "granted"
} else if (Notification.permission !== 'denied') {
return "waiting-permission"
} else {
return "denied"
}
}

View file

@ -21,7 +21,7 @@ class UnopenedChannelNotice extends Component {
}
receiveNewMessage = e => {
if (location.pathname.startsWith("/💌") || location.pathname.startsWith("/%F0%9F%92%8C")) {
if (location.pathname.startsWith("/gether") || location.pathname.startsWith("/%F0%9F%92%8C")) {
return
}
let channels = this.state.unopenedChannels;
@ -40,7 +40,7 @@ class UnopenedChannelNotice extends Component {
if (this.state.visible) {
const channels = this.state.unopenedChannels.map(channel => {
return <a
href={"/💌/"+channel.adjusted_slug}
href={"/gether/"+channel.adjusted_slug}
style={{
background: "#66e2d5",
color: "black",
@ -70,7 +70,7 @@ class UnopenedChannelNotice extends Component {
fontSize: "24px",
verticalAlign: "-4px",
display: "inline-block",
marginRight: "3px"}}>💌</span> New Message from {channels}
marginRight: "3px"}}>gether</span> New Message from {channels}
<span style={{ color: "#fefa87"}}>(beta testing)</span>
</div>
);
@ -79,7 +79,7 @@ class UnopenedChannelNotice extends Component {
}
export default function getUnopenedChannels(user, successCb) {
if (location.pathname.startsWith("/💌") || location.pathname.startsWith("/%F0%9F%92%8C")) {
if (location.pathname.startsWith("/gether") || location.pathname.startsWith("/%F0%9F%92%8C")) {
return
}
fetch('/chat_channels?state=unopened', {

View file

@ -15,6 +15,26 @@ class Message < ApplicationRecord
HexComparer.new(color_options).brightness(0.9)
end
def send_push
return unless chat_channel.channel_type == "direct"
reciever_ids = chat_channel.chat_channel_memberships.
where.not(user_id: user.id).pluck(:user_id)
PushNotificationSubscription.where(user_id: reciever_ids).each do |sub|
p sub
Webpush.payload_send(
endpoint: sub.endpoint,
message: message_html,
p256dh: sub.p256dh_key,
auth: sub.auth_key,
ttl: 24 * 60 * 60,
vapid: {
subject: "https://dev.to",
public_key: ENV["VAPID_PUBLIC_KEY"],
private_key: ENV["VAPID_PRIVATE_KEY"],
})
end
end
private
def update_chat_channel_last_message_at
@ -24,12 +44,12 @@ class Message < ApplicationRecord
def update_all_has_unopened_messages_statuses
chat_channel.
chat_channel_memberships.
where("last_opened_at < ?", 1.seconds.ago).
where("last_opened_at < ?", 4.seconds.ago).
where.
not(user_id: user_id).
update_all(has_unopened_messages: true)
end
# handle_asynchronously :update_all_has_unopened_messages_statuses
handle_asynchronously :update_all_has_unopened_messages_statuses
def evaluate_markdown
self.message_html = MarkdownParser.new(message_markdown).evaluate_inline_markdown

View file

@ -0,0 +1,8 @@
class PushNotificationSubscription < ApplicationRecord
validates :endpoint, presence: true, uniqueness: true
validates :user_id, presence: true
validates :p256dh_key, presence: true, uniqueness: true
validates :auth_key, presence: true, uniqueness: true
validates :notification_type, presence: true, inclusion: { in: %w(browser) }
belongs_to :user
end

View file

@ -28,6 +28,7 @@ class User < ApplicationRecord
has_many :tweets
has_many :chat_channel_memberships
has_many :chat_channels, through: :chat_channel_memberships
has_many :notification_subscriptions
mount_uploader :profile_image, ProfileImageUploader

View file

@ -33,4 +33,7 @@
<style>
footer {display: none}
#page-content {max-height:100vh}
</style>
</style>
<script>
window.vapidPublicKey = new Uint8Array(<%= Base64.urlsafe_decode64(ENV['VAPID_PUBLIC_KEY']).bytes %>);
</script>

View file

@ -64,6 +64,8 @@ keys = [
"TWITTER_ACCESS_TOKEN_SECRET",
"TWITTER_KEY",
"TWITTER_SECRET",
"VAPID_PUBLIC_KEY",
"VAPID_PRIVATE_KEY"
].freeze
missing = []

View file

@ -94,13 +94,15 @@ Rails.application.routes.draw do
resources :additional_content_boxes, only: [:index]
resources :videos, only: [:create, :new]
resources :video_states, only: [:create]
resources :push_notification_subscriptions, only: [:create]
get "/notifications/:username" => "notifications#index"
patch "/onboarding_update" => "users#onboarding_update"
get "email_subscriptions/unsubscribe"
post "/chat_channels/:id/moderate" => "chat_channels#moderate"
post "/chat_channels/:id/open" => "chat_channels#open"
get "/💌" => "chat_channels#index"
get "/💌/:slug" => "chat_channels#index"
get "/gether" => "chat_channels#index"
get "/gether/:slug" => "chat_channels#index"
post "/pusher/auth" => "pusher#auth"

View file

@ -135,3 +135,7 @@ PUSHER_APP_ID: "REPLACEME"
PUSHER_CLUSTER: "REPLACEME"
PUSHER_KEY: "REPLACEME"
PUSHER_SECRET: "REPLACEME"
#For browser webpush notifications (webpush gem)
VAPID_PUBLIC_KEY: "REPLACEME"
VAPID_PRIVATE_KEY: "REPLACEME"

View file

@ -0,0 +1,12 @@
class CreatePushNotificationSubscriptions < ActiveRecord::Migration[5.1]
def change
create_table :push_notification_subscriptions do |t|
t.string :endpoint
t.string :p256dh_key
t.string :auth_key
t.string :notification_type
t.references :user, foreign_key: true, null: false
t.timestamps
end
end
end

View file

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 20180606155327) do
ActiveRecord::Schema.define(version: 20180609191539) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@ -469,6 +469,17 @@ ActiveRecord::Schema.define(version: 20180606155327) do
t.string "website_url"
end
create_table "push_notification_subscriptions", force: :cascade do |t|
t.string "auth_key"
t.datetime "created_at", null: false
t.string "endpoint"
t.string "notification_type"
t.string "p256dh_key"
t.datetime "updated_at", null: false
t.bigint "user_id", null: false
t.index ["user_id"], name: "index_push_notification_subscriptions_on_user_id"
end
create_table "reactions", id: :serial, force: :cascade do |t|
t.string "category"
t.datetime "created_at", null: false
@ -695,4 +706,5 @@ ActiveRecord::Schema.define(version: 20180606155327) do
add_foreign_key "chat_channel_memberships", "users"
add_foreign_key "messages", "chat_channels"
add_foreign_key "messages", "users"
add_foreign_key "push_notification_subscriptions", "users"
end

View file

@ -0,0 +1,5 @@
require 'rails_helper'
RSpec.describe PushNotificationSubscription, type: :model do
pending "add some examples to (or delete) #{__FILE__}"
end