diff --git a/assets/audio/pop_up_alert.mp3 b/assets/audio/pop_up_alert.mp3 new file mode 100644 index 00000000..ec740f1e Binary files /dev/null and b/assets/audio/pop_up_alert.mp3 differ diff --git a/assets/icons/icon-100.png b/assets/icons/icon-100.png new file mode 100644 index 00000000..9d287d41 Binary files /dev/null and b/assets/icons/icon-100.png differ diff --git a/assets/icons/icon-1024.png b/assets/icons/icon-1024.png new file mode 100644 index 00000000..a2122e21 Binary files /dev/null and b/assets/icons/icon-1024.png differ diff --git a/assets/icons/icon-167.png b/assets/icons/icon-167.png new file mode 100644 index 00000000..7ba945a4 Binary files /dev/null and b/assets/icons/icon-167.png differ diff --git a/assets/icons/icon-196.png b/assets/icons/icon-196.png new file mode 100644 index 00000000..b95193da Binary files /dev/null and b/assets/icons/icon-196.png differ diff --git a/assets/icons/icon-29.png b/assets/icons/icon-29.png new file mode 100644 index 00000000..8debaff9 Binary files /dev/null and b/assets/icons/icon-29.png differ diff --git a/assets/icons/icon-40.png b/assets/icons/icon-40.png new file mode 100644 index 00000000..4cf59273 Binary files /dev/null and b/assets/icons/icon-40.png differ diff --git a/assets/icons/icon-40@2x.png b/assets/icons/icon-40@2x.png new file mode 100644 index 00000000..d7e5d761 Binary files /dev/null and b/assets/icons/icon-40@2x.png differ diff --git a/assets/icons/icon-50.png b/assets/icons/icon-50.png new file mode 100644 index 00000000..f66682e5 Binary files /dev/null and b/assets/icons/icon-50.png differ diff --git a/assets/icons/icon-512.png b/assets/icons/icon-512.png new file mode 100644 index 00000000..80c063ca Binary files /dev/null and b/assets/icons/icon-512.png differ diff --git a/assets/icons/icon-55.png b/assets/icons/icon-55.png new file mode 100644 index 00000000..9199e22e Binary files /dev/null and b/assets/icons/icon-55.png differ diff --git a/assets/icons/icon-58.png b/assets/icons/icon-58.png new file mode 100644 index 00000000..27db7593 Binary files /dev/null and b/assets/icons/icon-58.png differ diff --git a/assets/icons/icon-60.png b/assets/icons/icon-60.png new file mode 100644 index 00000000..d64c310e Binary files /dev/null and b/assets/icons/icon-60.png differ diff --git a/assets/icons/icon-60@2x.png b/assets/icons/icon-60@2x.png new file mode 100644 index 00000000..7531341e Binary files /dev/null and b/assets/icons/icon-60@2x.png differ diff --git a/assets/icons/icon-60@3x.png b/assets/icons/icon-60@3x.png new file mode 100644 index 00000000..91d52cab Binary files /dev/null and b/assets/icons/icon-60@3x.png differ diff --git a/assets/icons/icon-72.png b/assets/icons/icon-72.png new file mode 100644 index 00000000..f3462f8a Binary files /dev/null and b/assets/icons/icon-72.png differ diff --git a/assets/icons/icon-72@2x.png b/assets/icons/icon-72@2x.png new file mode 100644 index 00000000..55b1473a Binary files /dev/null and b/assets/icons/icon-72@2x.png differ diff --git a/assets/icons/icon-76.png b/assets/icons/icon-76.png new file mode 100644 index 00000000..07c56a7b Binary files /dev/null and b/assets/icons/icon-76.png differ diff --git a/assets/icons/icon-76@2x.png b/assets/icons/icon-76@2x.png new file mode 100644 index 00000000..6c86782d Binary files /dev/null and b/assets/icons/icon-76@2x.png differ diff --git a/assets/icons/icon-87.png b/assets/icons/icon-87.png new file mode 100644 index 00000000..f1b9db82 Binary files /dev/null and b/assets/icons/icon-87.png differ diff --git a/assets/icons/icon.png b/assets/icons/icon.png new file mode 100644 index 00000000..a897e900 Binary files /dev/null and b/assets/icons/icon.png differ diff --git a/assets/icons/icon@2x.png b/assets/icons/icon@2x.png new file mode 100644 index 00000000..d8c11b76 Binary files /dev/null and b/assets/icons/icon@2x.png differ diff --git a/bin/push-test.js b/bin/push-test.js new file mode 100755 index 00000000..96c1160b --- /dev/null +++ b/bin/push-test.js @@ -0,0 +1,23 @@ +#!/usr/bin/env node + +var webpush = require('web-push'); +// https://console.firebase.google.com +var SERVER_API_KEY=""; +// From push-worker subscription +var pushSubscription = {"endpoint":"...","expirationTime":null,"keys":{"p256dh":"..."}}; + +webpush.setGCMAPIKey(SERVER_API_KEY); +webpush.sendNotification(pushSubscription, JSON.stringify({ + tag: Date.now(), + method:"push", + title: "Montage Popcorn", + icon: '/assets/icons/icon-196.png', + badge:'/assets/icons/icon-128.png', + sound: '/assets/audio/pop_up_alert.mp3', + body: "There's a new trailer!" +})).then(function (res) { + console.log('ok', res); +}).catch(function (err) { + console.error('err', err); +}); + \ No newline at end of file diff --git a/core/push-manager.js b/core/push-manager.js new file mode 100644 index 00000000..2e804abc --- /dev/null +++ b/core/push-manager.js @@ -0,0 +1,92 @@ +var Promise = require("montage/core/promise").Promise; +var ServiceWorker = require('./service-worker').ServiceWorker; + +// +// ServiceWorker +// + +var PushManager = { + + getWorker: function() { + return ServiceWorker.getWorkerRegistration('push-worker.js'); + }, + + getSubscription: function() { + return this.getWorker().then(function(registration) { + if (!registration.pushManager) { + return Promise.reject('Service worker push not supported.'); + } else { + return registration.pushManager.getSubscription(); + } + }); + }, + + hasSubscription: function() { + return this.getSubscription().then(function (subscription) { + return subscription && subscription.length > 0; + }); + }, + + subscribe: function() { + + return this.getWorker().then(function(registration) { + if (!registration.pushManager) { + return Promise.reject('Service worker push not supported.'); + } else { + // Use the PushManager to get the user's subscription to the push service. + return registration.pushManager.subscribe({ + userVisibleOnly: true + }); + } + }); + }, + + unsubscribe: function() { + var self = this; + return self.getSubscription().then(function (subscription) { + return subscription.unsubscribe().then(function () { + return self.getWorker().then(function (worker) { + return worker.unregister(); + }); + }); + }); + }, + + send: function(subscription, body, options) { + + options = Object.assign({ + title: document.title, + icon: '/assets/icons/icon-196.png', + badge:'/assets/icons/icon-128.png', + sound: '/assets/audio/pop_up_alert.mp3', + data: location.href, + tag: undefined, + vibrate: undefined, + lang: undefined, + delay: 0, + ttl: 0 + }, options || {}); + + var msg = { + body: body, + title: options.title, + picture: options.picture, + sound: options.sound, + icon: options.icon, + badge: options.badge, + tag: options.tag, + vibrate: options.vibrate, + lang: options.lang, + actions: options.actions, + data: options.data + }; + + // Send to worker + return this.getWorker().then(function (worker) { + return ServiceWorker.sendWorkerMsg(worker, msg); + }); + } +}; + +exports.PushManager = PushManager; + diff --git a/manifest.json b/manifest.json index 27a5f5ff..cdecc896 100644 --- a/manifest.json +++ b/manifest.json @@ -24,6 +24,9 @@ "theme_color": "#000", "background_color": "#000", "display": "standalone", + "permissions":["notifications"], + "gcm_sender_id":"743128909939", + "gcm_user_visible_only":true, "montage_manifest_version": 1, "files": { "locale": { diff --git a/package.json b/package.json index 53f8f629..5fd1e87f 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,8 @@ "digit": "^3.0.2", "montage": "montagejs/montage#master", "query-params": "0.0.1", - "url": "^0.11.0" + "url": "^0.11.0", + "web-push": "^3.3.0" }, "devDependencies": { "jshint": "^2.9.5", diff --git a/push-worker.js b/push-worker.js new file mode 100644 index 00000000..bd81c22f --- /dev/null +++ b/push-worker.js @@ -0,0 +1,218 @@ +/*global define:false, console, self, Promise */ + +// https://www.chromium.org/Home/chromium-security/prefer-secure-origins-for-powerful-new-features +// https://developers.google.com/web/fundamentals/engage-and-retain/push-notifications/permissions-subscriptions +// https://github.com/w3c/ServiceWorker/blob/master/explainer.md +// chrome://inspect/#service-workers +// https://serviceworke.rs + +// +// Env Setttings +// + +// It's replaced unconditionally to preserve the expected behavior +// in programs even if there's ever a native finally. +Promise.prototype['finally'] = function finallyPolyfill(callback) { + var constructor = this.constructor; + + return this.then(function(value) { + return constructor.resolve(callback()).then(function() { + return value; + }); + }, function(reason) { + return constructor.resolve(callback()).then(function() { + throw reason; + }); + }); +}; + +var DEBUG = false; + +// +// Utils +// + +function log(msg, obj) { + console.log('PushWorker', msg, DEBUG ? obj : undefined); +} + +function postMessage(msg) { + if (DEBUG) { + log("postMessage", msg); + } + return self.clients.matchAll().then(function(clients) { + return Promise.all(clients.map(function(client) { + return client.postMessage(msg); + })); + }); +} + +function showNotification(payload) { + + // Cast has object + if (typeof payload === 'string') { + payload = { + title: payload + }; + } + + // Clear bad icons + /* + if ( + typeof payload.icon === 'string' && + payload.icon.indexOf('https://') !== 0 + ) { + delete payload.icon; + } + + // Clear bad badge + if ( + typeof payload.badge === 'string' && + payload.badge.indexOf('https://') !== 0 + ) { + delete payload.badge; + } + */ + + // Force requireInteraction + if (typeof payload.requireInteraction === 'undefined') { + payload.requireInteraction = true; + } + + if (typeof payload.actions === 'undefined') { + + // Open/Close payload.data + payload.actions = payload.data ? [ + { + action: 'open', + title: 'Open' + }, + { + action: 'close', + title: 'Dismiss' + } + + // Close (no payload.data) + ] : [ + { + action: 'close', + title: 'Close' + } + ]; + } + + // Send via postMessage + postMessage({ + event: 'push', + data: payload + }); + + return self.registration.showNotification(payload.title, { + lang: payload.lang || 'en', + body: payload.body || 'Hello!', + tag: payload.tag || payload.title, + icon: payload.icon, + badge: payload.badge, + actions: payload.actions, + data: payload.data, + renotify: !!payload.renotify, + requireInteraction: !!payload.requireInteraction, + vibrate: payload.vibrate, + sound: payload.sound, + silent: (payload.silent || (!payload.sound && !payload.vibrate)) + }); +} + +function openUrl(url) { + return self.clients.matchAll({ + includeUncontrolled: true, + type: 'window' + }).then(function(clientList) { + + var clientListMatchUrl; + + // Look for a match + if (url) { + clientListMatchUrl = clientListMatchUrl && clientListMatchUrl.filter(function (client) { + return String(client.url).indexOf(url) === 0; + }); + + if (clientListMatchUrl && clientListMatchUrl.length === 0) { + clientListMatchUrl = clientList; + } + } + + if (clientList && clientList.length > 0) { + return clientList[0].focus(); + } else if (url) { + return self.clients.openWindow(url); + } + }); +} + +// +// Worker +// + +log('Started', self); + +self.addEventListener('install', function(event) { + log('Install...', event); + event.waitUntil(self.skipWaiting().finally(function () { + log('Installed', event); + })); +}); + +self.addEventListener('activate', function(event) { + event.waitUntil(self.skipWaiting().then(function () { + self.clients.claim(); + }).finally(function () { + log('Activated', event); + })); +}); + +self.addEventListener('message', function (event) { + log('Push event received', event); + event.waitUntil( + showNotification(event.data) + ); +}); + +// Register event listener for the 'push' event. +var lastPayload; +self.addEventListener('push', function(event) { + try { + var payload = event.data ? JSON.parse(event.data.text()) : {}; + + // Keep the service worker alive until the notification is created. + event.waitUntil( + showNotification(payload) + ); + + } catch(err) { + log('Push message parse failed', err); + } +}); + +self.addEventListener('notificationclick', function(event) { + log('Notification clicked', event); + var action = event.action || 'open'; + if (action === 'open') { + event.notification.close(); + event.waitUntil(openUrl(event.notification.data)); + } else if (action === 'close') { + event.notification.close(); + } +}, false); + +self.addEventListener('message', function (event) { + if (event.data === 'PushTest') { + log('PushTest...', event); + event.waitUntil( + showNotification({ + title: 'Push Notification Test', + data: self.location.href + }) + ); + } +}); \ No newline at end of file diff --git a/ui/main.reel/main.js b/ui/main.reel/main.js index 172b6e33..864f3780 100644 --- a/ui/main.reel/main.js +++ b/ui/main.reel/main.js @@ -2,7 +2,8 @@ var Component = require("montage/ui/component").Component, sharedMoviesService = require("core/tmdb-service").shared, defaultLocalizer = require("montage/core/localizer").defaultLocalizer, - CacheManager = require('core/cache-manager.js').CacheManager; + CacheManager = require('core/cache-manager').CacheManager, + PushManager = require('core/push-manager').PushManager; //TODO use details in toggle buttons //TODO do not use matte toggle buttons @@ -10,25 +11,29 @@ exports.Main = Component.specialize({ constructor: { value: function Main () { + + this.initLocal(); + this.initCache(); + this.initPush(); - /* - // Test localize - defaultLocalizer.locale = 'fr'; - defaultLocalizer.localize("hello").then(function (localized) { - console.log(localized); - }); - */ + // Init App + this.application.addEventListener( "openTrailer", this, false); + this.canDrawGate.setField("moviesLoaded", false); + this._initialDataLoad = this.moviesService.load(); + } + }, + initLocal: { + value: function () { var localeParam = this.getParameterByName('lang'); if (localeParam) { defaultLocalizer.locale = localeParam; } + } + }, - this.application.addEventListener( "openTrailer", this, false); - - this.canDrawGate.setField("moviesLoaded", false); - this._initialDataLoad = this.moviesService.load(); - + initCache: { + value: function () { // Add events CacheManager.events.error = function (error) { console.error('MainUpdate', 'error', error); @@ -54,6 +59,35 @@ exports.Main = Component.specialize({ } }, + initPush: { + value: function () { + PushManager.hasSubscription().then(function (hasSubscription) { + if (!hasSubscription) { + return PushManager.subscribe().then(function (subscription) { + return PushManager.send(subscription, 'Welcome back!', { + // options goes here picture,badge,tag + }).then(function () { + return subscription; + }); + }); + } else { + // Already subscribed + return PushManager.getSubscription(function (subscription) { + return PushManager.send(subscription, 'Push notification enabled!', { + // options goes here picture,badge,tag + }).then(function () { + return subscription; + }); + }); + } + }).then(function (subscription) { + console.info('Push Notification subscription', JSON.stringify(subscription)); + }).catch(function (err) { + console.error('Push Notification Error', err); + }); + } + }, + getParameterByName: { value: function (name, url) { if (!url) {