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.
3
Gemfile
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
After Width: | Height: | Size: 844 B |
After Width: | Height: | Size: 7.8 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 1.9 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 1.2 KiB |
After Width: | Height: | Size: 1.4 KiB |
After Width: | Height: | Size: 1000 B |
After Width: | Height: | Size: 1.2 KiB |
|
@ -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');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 };
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -14,6 +14,7 @@
|
|||
{{#if showTop}}
|
||||
{{custom-html name="top"}}
|
||||
{{/if}}
|
||||
{{notification-consent-banner}}
|
||||
{{global-notice}}
|
||||
{{create-topics-notice}}
|
||||
</div>
|
||||
|
|
|
@ -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"}}
|
||||
|
|
|
@ -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}}
|
|
@ -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 %>
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.push-notification-prompt .consent_banner {
|
||||
margin-bottom: 30px;
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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'
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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"
|
||||
|
|
|
@ -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.'
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
|
@ -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
|
|
@ -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
|