Add browser push notifications (attempt) (#412)
This commit is contained in:
parent
bf56ad5cb4
commit
7bc3e60283
21 changed files with 240 additions and 13 deletions
1
Gemfile
1
Gemfile
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
8
app/models/push_notification_subscription.rb
Normal file
8
app/models/push_notification_subscription.rb
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -64,6 +64,8 @@ keys = [
|
|||
"TWITTER_ACCESS_TOKEN_SECRET",
|
||||
"TWITTER_KEY",
|
||||
"TWITTER_SECRET",
|
||||
"VAPID_PUBLIC_KEY",
|
||||
"VAPID_PRIVATE_KEY"
|
||||
].freeze
|
||||
|
||||
missing = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
14
db/schema.rb
14
db/schema.rb
|
|
@ -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
|
||||
|
|
|
|||
5
spec/models/push_notification_subscription_spec.rb
Normal file
5
spec/models/push_notification_subscription_spec.rb
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
require 'rails_helper'
|
||||
|
||||
RSpec.describe PushNotificationSubscription, type: :model do
|
||||
pending "add some examples to (or delete) #{__FILE__}"
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue