Feature: Push notifications for Android (#5792)

* Feature: Push notifications for Android

Notification config for desktop and mobile are merged.

Desktop notifications stay as they are for desktop views.

If mobile mode, push notifications are enabled.

Added push notification subscriptions in their own table, rather than through
custom fields.

Notification banner prompts appear for both mobile and desktop when enabled.
This commit is contained in:
Jeff Wong 2018-05-04 15:31:48 -07:00 committed by GitHub
parent 4c9f6e192f
commit 91b31860a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 603 additions and 33 deletions

View File

@ -185,3 +185,6 @@ if ENV["IMPORT"] == "1"
gem 'ruby-bbcode-to-md', github: 'nlalonde/ruby-bbcode-to-md'
gem 'reverse_markdown'
end
gem 'hkdf', '0.3.0', require: false
gem 'webpush', '0.3.2', require: false

View File

@ -131,6 +131,7 @@ GEM
hashie (3.5.5)
highline (1.7.8)
hiredis (0.6.1)
hkdf (0.3.0)
htmlentities (4.3.4)
http_accept_language (2.0.5)
i18n (0.8.6)
@ -388,6 +389,9 @@ GEM
addressable (>= 2.3.6)
crack (>= 0.3.2)
hashdiff
webpush (0.3.2)
hkdf (~> 0.2)
jwt
PLATFORMS
ruby
@ -428,6 +432,7 @@ DEPENDENCIES
gc_tracer
highline
hiredis
hkdf (= 0.3.0)
htmlentities
http_accept_language (~> 2.0.5)
listen
@ -498,6 +503,7 @@ DEPENDENCIES
unf
unicorn
webmock
webpush (= 0.3.2)
BUNDLED WITH
1.16.1

Binary file not shown.

After

Width:  |  Height:  |  Size: 844 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@ -1,6 +1,13 @@
import computed from 'ember-addons/ember-computed-decorators';
import KeyValueStore from 'discourse/lib/key-value-store';
import { context } from 'discourse/lib/desktop-notifications';
import { context, confirmNotification } from 'discourse/lib/desktop-notifications';
import {
subscribe as subscribePushNotification,
unsubscribe as unsubscribePushNotification,
isPushNotificationsSupported,
keyValueStore as pushNotificationKeyValueStore,
userSubscriptionKey as pushNotificationUserSubscriptionKey
} from 'discourse/lib/push-notifications';
const keyValueStore = new KeyValueStore(context);
@ -28,11 +35,6 @@ export default Ember.Component.extend({
return typeof window.Notification === "undefined";
},
@computed("isNotSupported", "notificationsPermission")
isDefaultPermission(isNotSupported, notificationsPermission) {
return isNotSupported ? false : notificationsPermission === "default";
},
@computed("isNotSupported", "notificationsPermission")
isDeniedPermission(isNotSupported, notificationsPermission) {
return isNotSupported ? false : notificationsPermission === "denied";
@ -44,27 +46,65 @@ export default Ember.Component.extend({
},
@computed("isGrantedPermission", "notificationsDisabled")
isEnabled(isGrantedPermission, notificationsDisabled) {
isEnabledDesktop(isGrantedPermission, notificationsDisabled) {
return isGrantedPermission ? !notificationsDisabled : false;
},
actions: {
requestPermission() {
Notification.requestPermission(() => this.propertyDidChange('notificationsPermission'));
@computed
isEnabledPush: {
set(value) {
const user = this.currentUser;
if(!user) {
return false;
}
pushNotificationKeyValueStore.setItem(pushNotificationUserSubscriptionKey(user), value);
return pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user));
},
get() {
const user = this.currentUser;
return user ? pushNotificationKeyValueStore.getItem(pushNotificationUserSubscriptionKey(user)) : false;
}
},
isEnabled: Ember.computed.or("isEnabledDesktop", "isEnabledPush"),
isPushNotificationsPreferred() {
if(!this.site.mobileView) {
return false;
}
return isPushNotificationsSupported(this.site.mobileView);
},
actions: {
recheckPermission() {
this.propertyDidChange('notificationsPermission');
},
turnoff() {
this.set('notificationsDisabled', 'disabled');
this.propertyDidChange('notificationsPermission');
if(this.get('isEnabledDesktop')) {
this.set('notificationsDisabled', 'disabled');
this.propertyDidChange('notificationsPermission');
}
if(this.get('isEnabledPush')) {
unsubscribePushNotification(this.currentUser, () => {
this.set("isEnabledPush", '');
});
}
},
turnon() {
this.set('notificationsDisabled', '');
this.propertyDidChange('notificationsPermission');
if(this.isPushNotificationsPreferred()) {
subscribePushNotification(() => {
this.set("isEnabledPush", 'subscribed');
}, this.siteSettings.vapid_public_key_bytes);
}
else {
this.set('notificationsDisabled', '');
Notification.requestPermission(() => {
confirmNotification();
this.propertyDidChange('notificationsPermission');
});
}
}
}
});

View File

@ -0,0 +1,45 @@
import { default as computed } from 'ember-addons/ember-computed-decorators';
import {
keyValueStore as pushNotificationKeyValueStore
} from 'discourse/lib/push-notifications';
import { default as DesktopNotificationConfig } from 'discourse/components/desktop-notification-config';
const userDismissedPromptKey = "dismissed-prompt";
export default DesktopNotificationConfig.extend({
@computed
bannerDismissed: {
set(value) {
pushNotificationKeyValueStore.setItem(userDismissedPromptKey, value);
return pushNotificationKeyValueStore.getItem(userDismissedPromptKey);
},
get() {
return pushNotificationKeyValueStore.getItem(userDismissedPromptKey);
}
},
@computed("isNotSupported", "isEnabled", "bannerDismissed", "currentUser.reply_count", "currentUser.topic_count")
showNotificationPromptBanner(isNotSupported, isEnabled, bannerDismissed, replyCount, topicCount) {
return (this.siteSettings.push_notifications_prompt &&
!isNotSupported &&
this.currentUser &&
replyCount + topicCount > 0 &&
Notification.permission !== "denied" &&
Notification.permission !== "granted" &&
!isEnabled &&
!bannerDismissed
);
},
actions: {
turnon() {
this._super();
this.set("bannerDismissed", true);
},
dismiss() {
this.set("bannerDismissed", true);
}
}
});

View File

@ -2,8 +2,14 @@
import {
init as initDesktopNotifications,
onNotification,
alertChannel
alertChannel,
disable as disableDesktopNotifications,
} from 'discourse/lib/desktop-notifications';
import {
register as registerPushNotifications,
unsubscribe as unsubscribePushNotifications,
isPushNotificationsEnabled
} from 'discourse/lib/push-notifications';
export default {
name: 'subscribe-user-notifications',
@ -11,14 +17,9 @@ export default {
initialize(container) {
const user = container.lookup('current-user:main');
const keyValueStore = container.lookup('key-value-store:main');
const bus = container.lookup('message-bus:main');
const appEvents = container.lookup('app-events:main');
// clear old cached notifications, we used to store in local storage
// TODO 2017 delete this line
keyValueStore.remove('recent-notifications');
if (user) {
if (user.get('staff')) {
bus.subscribe('/flagged_counts', data => {
@ -87,6 +88,7 @@ export default {
const site = container.lookup('site:main');
const siteSettings = container.lookup('site-settings:main');
const router = container.lookup('router:main');
bus.subscribe("/categories", data => {
_.each(data.categories, c => site.updateCategory(c));
@ -100,9 +102,16 @@ export default {
});
if (!Ember.testing) {
if (!site.mobileView) {
bus.subscribe(alertChannel(user), data => onNotification(data, user));
initDesktopNotifications(bus, appEvents);
bus.subscribe(alertChannel(user), data => onNotification(data, user));
initDesktopNotifications(bus, appEvents);
if(isPushNotificationsEnabled(user, site.mobileView)) {
disableDesktopNotifications();
registerPushNotifications(Discourse.User.current(), site.mobileView, router, appEvents);
}
else {
unsubscribePushNotifications(user);
}
}
}

View File

@ -55,6 +55,22 @@ function init(messageBus, appEvents) {
}
}
function confirmNotification() {
const notification = new Notification(I18n.t("notifications.popup.confirm_title", {site_title: Discourse.SiteSettings.title}), {
body: I18n.t("notifications.popup.confirm_body"),
icon: Discourse.SiteSettings.logo_small_url || Discourse.SiteSettings.logo_url,
tag: "confirm-subscription"
});
const clickEventHandler = () => notification.close();
notification.addEventListener('click', clickEventHandler);
setTimeout(() => {
notification.close();
notification.removeEventListener('click', clickEventHandler);
}, 10 * 1000);
}
// This function is only called if permission was granted
function setupNotifications(appEvents) {
@ -167,4 +183,8 @@ function unsubscribe(bus, user) {
bus.unsubscribe(alertChannel(user));
}
export { context, init, onNotification, unsubscribe, alertChannel };
function disable() {
keyValueStore.setItem('notifications-disabled', 'disabled');
}
export { context, init, onNotification, unsubscribe, alertChannel, confirmNotification, disable };

View File

@ -0,0 +1,119 @@
import { ajax } from 'discourse/lib/ajax';
import KeyValueStore from 'discourse/lib/key-value-store';
export const keyValueStore = new KeyValueStore("discourse_push_notifications_");
export function userSubscriptionKey(user) {
return `subscribed-${user.get('id')}`;
}
function sendSubscriptionToServer(subscription, sendConfirmation) {
ajax('/push_notifications/subscribe', {
type: 'POST',
data: { subscription: subscription.toJSON(), send_confirmation: sendConfirmation }
});
}
function userAgentVersionChecker(agent, version, mobileView) {
const uaMatch = navigator.userAgent.match(new RegExp(`${agent}\/(\\d+)\\.\\d`));
if (uaMatch && mobileView) return false;
if (!uaMatch || parseInt(uaMatch[1]) < version) return false;
return true;
}
function resetIdle() {
if('controller' in navigator.serviceWorker && navigator.serviceWorker.controller != null) {
navigator.serviceWorker.controller.postMessage({lastAction: Date.now()});
}
}
function setupActivityListeners(appEvents) {
window.addEventListener("focus", resetIdle);
if (document) {
document.addEventListener("scroll", resetIdle);
}
appEvents.on('page:changed', resetIdle);
}
export function isPushNotificationsSupported(mobileView) {
if (!(('serviceWorker' in navigator) &&
(ServiceWorkerRegistration &&
(typeof(Notification) !== "undefined") &&
('showNotification' in ServiceWorkerRegistration.prototype) &&
('PushManager' in window)))) {
return false;
}
if ((!userAgentVersionChecker('Firefox', 44, mobileView)) &&
(!userAgentVersionChecker('Chrome', 50))) {
return false;
}
return true;
}
export function isPushNotificationsEnabled(user, mobileView) {
return user && isPushNotificationsSupported(mobileView) && keyValueStore.getItem(userSubscriptionKey(user));
}
export function register(user, mobileView, router, appEvents) {
if (!isPushNotificationsSupported(mobileView)) return;
if (Notification.permission === 'denied' || !user) return;
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
serviceWorkerRegistration.pushManager.getSubscription().then(subscription => {
if (subscription) {
sendSubscriptionToServer(subscription, false);
// Resync localStorage
keyValueStore.setItem(userSubscriptionKey(user), 'subscribed');
}
setupActivityListeners(appEvents);
}).catch(e => Ember.Logger.error(e));
});
navigator.serviceWorker.addEventListener('message', (event) => {
if ('url' in event.data) {
const url = event.data.url;
router.handleURL(url);
}
});
}
export function subscribe(callback, applicationServerKey, mobileView) {
if (!isPushNotificationsSupported(mobileView)) return;
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
serviceWorkerRegistration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: new Uint8Array(applicationServerKey.split("|")) // eslint-disable-line no-undef
}).then(subscription => {
sendSubscriptionToServer(subscription, true);
if (callback) callback();
}).catch(e => Ember.Logger.error(e));
});
}
export function unsubscribe(user, callback, mobileView) {
if (!isPushNotificationsSupported(mobileView)) return;
keyValueStore.setItem(userSubscriptionKey(user), '');
navigator.serviceWorker.ready.then(serviceWorkerRegistration => {
serviceWorkerRegistration.pushManager.getSubscription().then(subscription => {
if (subscription) {
subscription.unsubscribe().then((successful) => {
if (successful) {
ajax('/push_notifications/unsubscribe', {
type: 'POST',
data: { subscription: subscription.toJSON() }
});
}
});
}
}).catch(e => Ember.Logger.error(e));
if (callback) callback();
});
}

View File

@ -14,6 +14,7 @@
{{#if showTop}}
{{custom-html name="top"}}
{{/if}}
{{notification-consent-banner}}
{{global-notice}}
{{create-topics-notice}}
</div>

View File

@ -1,15 +1,10 @@
{{#if isNotSupported}}
{{d-button icon="bell-slash" label="user.desktop_notifications.not_supported" disabled="true"}}
{{/if}}
{{#if isDefaultPermission}}
{{d-button icon="bell-slash" label="user.desktop_notifications.perm_default" action="requestPermission"}}
{{/if}}
{{#if isDeniedPermission}}
{{d-button icon="bell-slash" label="user.desktop_notifications.perm_denied_btn" action="recheckPermission" disabled='true'}}
{{i18n "user.desktop_notifications.perm_denied_expl"}}
{{/if}}
{{#if isGrantedPermission}}
{{else}}
{{#if isEnabled}}
{{d-button icon="bell-slash-o" label="user.desktop_notifications.disable" action="turnoff"}}
{{i18n "user.desktop_notifications.currently_enabled"}}

View File

@ -0,0 +1,8 @@
{{#if showNotificationPromptBanner}}
<div class="row">
<div class="consent_banner alert alert-info">
<div class="close" {{action "dismiss"}}><i class="fa fa-times d-icon d-icon-times"></i></div>
{{i18n 'user.desktop_notifications.consent_prompt'}} <a {{action "turnon"}}>{{i18n 'user.desktop_notifications.enable'}}</a>.
</div>
</div>
{{/if}}

View File

@ -95,6 +95,80 @@ self.addEventListener('fetch', function(event) {
// handled by the browser as if there were no service worker involvement.
});
const idleThresholdTime = 1000 * 10; // 10 seconds
var lastAction = -1;
function isIdle() {
return lastAction + idleThresholdTime < Date.now();
}
function showNotification(title, body, icon, badge, tag, baseUrl, url) {
var notificationOptions = {
body: body,
icon: icon,
badge: badge,
data: { url: url, baseUrl: baseUrl },
tag: tag
}
return self.registration.showNotification(title, notificationOptions);
}
self.addEventListener('push', function(event) {
var payload = event.data.json();
if(!isIdle() && payload.hide_when_active) {
return false;
}
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.badge, payload.tag, payload.base_url, 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;
var baseUrl = event.notification.data.baseUrl;
// 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) {
var reusedClientWindow = clientList.some(function(client) {
if (client.url === baseUrl + url && 'focus' in client) {
client.focus();
return true;
}
if ('postMessage' in client && 'focus' in client) {
client.focus();
client.postMessage({ url: url });
return true;
}
return false;
});
if (!reusedClientWindow && clients.openWindow) return clients.openWindow(baseUrl + url);
})
);
});
self.addEventListener('message', event => {
if('lastAction' in event.data){
lastAction = event.data.lastAction;
}});
<% DiscoursePluginRegistry.service_workers.each do |js| %>
<%=raw "#{File.read(js)}" %>
<% end %>

View File

@ -0,0 +1,3 @@
.push-notification-prompt .consent_banner {
margin-bottom: 30px;
}

View File

@ -0,0 +1,21 @@
class PushNotificationController < ApplicationController
layout false
before_action :ensure_logged_in
skip_before_action :preload_json
def subscribe
PushNotificationPusher.subscribe(current_user, push_params, params[:send_confirmation])
render json: success_json
end
def unsubscribe
PushNotificationPusher.unsubscribe(current_user, push_params)
render json: success_json
end
private
def push_params
params.require(:subscription).permit(:endpoint, keys: [:p256dh, :auth])
end
end

View File

@ -0,0 +1,8 @@
module Jobs
class SendPushNotification < Jobs::Base
def execute(args)
user = User.find(args[:user_id])
PushNotificationPusher.push(user, args[:payload])
end
end
end

View File

@ -0,0 +1,13 @@
class PushSubscription < ActiveRecord::Base
belongs_to :user
end
# == Schema Information
#
# Table name: push_subscription
#
# id :integer not null, primary key
# user_id :integer not null
# data :string not null
# created_at :datetime not null
# updated_at :datetime not null

View File

@ -80,6 +80,8 @@ class User < ActiveRecord::Base
has_one :api_key, dependent: :destroy
has_many :push_subscriptions, dependent: :destroy
belongs_to :uploaded_avatar, class_name: 'Upload'
has_many :acting_group_histories, dependent: :destroy, foreign_key: :acting_user_id, class_name: 'GroupHistory'

View File

@ -0,0 +1,80 @@
require_dependency 'webpush'
class PushNotificationPusher
def self.push(user, payload)
subscriptions(user).each do |subscription|
subscription = JSON.parse(subscription.data)
message = {
title: I18n.t(
"discourse_push_notifications.popup.#{Notification.types[payload[:notification_type]]}",
site_title: SiteSetting.title,
topic: payload[:topic_title],
username: payload[:username]
),
body: payload[:excerpt],
badge: get_badge,
icon: ActionController::Base.helpers.image_url("push-notifications/#{Notification.types[payload[:notification_type]]}.png"),
tag: "#{Discourse.current_hostname}-#{payload[:topic_id]}",
base_url: Discourse.base_url,
url: payload[:post_url],
hide_when_active: true
}
send_notification user, subscription, message
end
end
def self.subscriptions(user)
user.push_subscriptions
end
def self.clear_subscriptions(user)
user.push_subscriptions.clear
end
def self.subscribe(user, subscription, send_confirmation)
PushSubscription.create user: user, data: subscription.to_json
if send_confirmation == "true"
message = {
title: I18n.t("discourse_push_notifications.popup.confirm_title",
site_title: SiteSetting.title),
body: I18n.t("discourse_push_notifications.popup.confirm_body"),
icon: ActionController::Base.helpers.image_url("push-notifications/check.png"),
badge: get_badge,
tag: "#{Discourse.current_hostname}-subscription"
}
send_notification user, subscription, message
end
end
def self.unsubscribe(user, subscription)
PushSubscription.find_by(user: user, data: subscription.to_json)&.destroy
end
protected
def self.get_badge
return !SiteSetting.push_notifications_icon_url.blank? ?
SiteSetting.push_notifications_icon_url :
ActionController::Base.helpers.image_url("push-notifications/discourse.png")
end
def self.send_notification(user, subscription, message)
begin
response = Webpush.payload_send(
endpoint: subscription["endpoint"],
message: message.to_json,
p256dh: subscription.dig("keys", "p256dh"),
auth: subscription.dig("keys", "auth"),
vapid: {
subject: Discourse.base_url,
public_key: SiteSetting.vapid_public_key,
private_key: SiteSetting.vapid_private_key
}
)
rescue Webpush::InvalidSubscription => e
unsubscribe user, subscription
end
end
end

View File

@ -0,0 +1,17 @@
require_dependency 'webpush'
if SiteSetting.vapid_public_key.blank? || SiteSetting.vapid_private_key.blank?
vapid_key = Webpush.generate_key
SiteSetting.vapid_public_key = vapid_key.public_key
SiteSetting.vapid_private_key = vapid_key.private_key
end
SiteSetting.vapid_public_key_bytes = Base64.urlsafe_decode64(SiteSetting.vapid_public_key).bytes.join("|")
DiscourseEvent.on(:post_notification_alert) do |user, payload|
Jobs.enqueue(:send_push_notification, user_id: user.id, payload: payload)
end
DiscourseEvent.on(:user_logged_out) do |user|
PushNotificationPusher.clear_subscriptions(user)
end

View File

@ -617,7 +617,7 @@ en:
notifications: "Notifications"
statistics: "Stats"
desktop_notifications:
label: "Desktop Notifications"
label: "Live Notifications"
not_supported: "Notifications are not supported on this browser. Sorry."
perm_default: "Turn On Notifications"
perm_denied_btn: "Permission Denied"
@ -627,6 +627,7 @@ en:
enable: "Enable Notifications"
currently_disabled: ""
each_browser_note: "Note: You have to change this setting on every browser you use."
consent_prompt: "Do you want live notifications when people reply to your posts?"
dismiss: 'Dismiss'
dismiss_notifications: "Dismiss All"
dismiss_notifications_tooltip: "Mark all unread notifications as read"
@ -1439,6 +1440,8 @@ en:
posted: '{{username}} posted in "{{topic}}" - {{site_title}}'
private_message: '{{username}} sent you a personal message in "{{topic}}" - {{site_title}}'
linked: '{{username}} linked to your post from "{{topic}}" - {{site_title}}'
confirm_title: 'Notifications enabled - %{site_title}'
confirm_body: 'Success! Notifications have been enabled.'
upload_selector:
title: "Add an image"
@ -2699,7 +2702,6 @@ en:
safe_mode:
enabled: "Safe mode is enabled, to exit safe mode close this browser window"
# This section is exported to the javascript for i18n in the admin section
admin_js:
type_to_filter: "type to filter..."
@ -3889,7 +3891,7 @@ en:
label: "New:"
add: "Add"
filter: "Search (URL or External URL)"
wizard_js:
wizard:
done: "Done"

View File

@ -1710,6 +1710,9 @@ en:
shared_drafts_category: "Enable the Shared Drafts feature by designating a category for topic drafts."
push_notifications_prompt: "Display user consent prompt."
push_notifications_icon_url: "The badge icon that appears in the notification corner. Recommended size is 96px by 96px."
errors:
invalid_email: "Invalid email address."
invalid_username: "There's no user with that username."
@ -3853,3 +3856,15 @@ en:
graph_title: "Search Count"
joined: "Joined"
discourse_push_notifications:
popup:
mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}'
group_mentioned: '%{username} mentioned you in "%{topic}" - %{site_title}'
quoted: '%{username} quoted you in "%{topic}" - %{site_title}'
replied: '%{username} replied to you in "%{topic}" - %{site_title}'
posted: '%{username} posted in "%{topic}" - %{site_title}'
private_message: '%{username} sent you a private message in "%{topic}" - %{site_title}'
linked: '%{username} linked to your post from "%{topic}" - %{site_title}'
confirm_title: 'Notifications enabled - %{site_title}'
confirm_body: 'Success! Notifications have been enabled.'

View File

@ -802,4 +802,7 @@ Discourse::Application.routes.draw do
get "*url", to: 'permalinks#show', constraints: PermalinkConstraint.new
post "/push_notifications/subscribe" => "push_notification#subscribe"
post "/push_notifications/unsubscribe" => "push_notification#unsubscribe"
end

View File

@ -229,6 +229,21 @@ basic:
enable_whispers:
client: true
default: false
push_notifications_prompt:
default: false
client: true
push_notifications_icon_url:
default: ''
vapid_public_key_bytes:
default: ''
client: true
hidden: true
vapid_public_key:
default: ''
hidden: true
vapid_private_key:
default: ''
hidden: true
login:
invite_only:

View File

@ -0,0 +1,9 @@
class CreatePushSubscription < ActiveRecord::Migration[5.1]
def change
create_table :push_subscriptions do |t|
t.integer :user_id, null: false
t.string :data, null: false
t.timestamps
end
end
end

View File

@ -0,0 +1,48 @@
require 'rails_helper'
describe PushNotificationController do
context "logged out" do
it "should not allow subscribe" do
get :subscribe, params: { username: "test", subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json
expect(response).not_to be_success
json = JSON.parse(response.body)
expect(response).not_to be_success
end
end
context "logged in" do
let(:user) { log_in }
it "should subscribe" do
get :subscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, send_confirmation: false, format: :json }, format: :json
expect(response).to be_success
json = JSON.parse(response.body)
expect(response).to be_success
end
it "should unsubscribe with existing subscription" do
sub = { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }
PushSubscription.create(user: user, data: sub.to_json)
get :unsubscribe, params: { username: user.username, subscription: sub, format: :json }, format: :json
expect(response).to be_success
json = JSON.parse(response.body)
expect(response).to be_success
end
it "should unsubscribe without subscription" do
get :unsubscribe, params: { username: user.username, subscription: { endpoint: "endpoint", keys: { p256dh: "256dh", auth: "auth" } }, format: :json }, format: :json
expect(response).to be_success
json = JSON.parse(response.body)
expect(response).to be_success
end
end
end

View File

@ -0,0 +1,14 @@
require 'rails_helper'
RSpec.describe PushNotificationPusher do
it "returns badges url by default" do
expect(PushNotificationPusher.get_badge).to eq("/assets/push-notifications/discourse.png")
end
it "returns custom badges url" do
SiteSetting.push_notifications_icon_url = "/test.png"
expect(PushNotificationPusher.get_badge).to eq("/test.png")
end
end