From 61520815de15a955b88fc320352b29160f07536d Mon Sep 17 00:00:00 2001 From: Josef Citrine Date: Sun, 12 Jun 2016 00:58:10 +0100 Subject: [PATCH] #25: Basic service worker notifications --- .../Api/Web/NotificationsController.php | 22 +++++- app/Http/routes.php | 1 + app/Jobs/SendNotifications.php | 2 +- .../Notifications/Drivers/NativeDriver.php | 55 +++++++++----- app/Library/Notifications/RecipientFinder.php | 38 ++++++++++ app/Models/Activity.php | 18 +++++ public/service-worker.js | 46 +++++++++++- .../directives/notification-list.html | 15 +++- .../app/controllers/application.coffee | 1 - .../app/directives/notification-list.coffee | 36 +++++++-- .../scripts/app/services/notifications.coffee | 73 +++++++++++++++++++ resources/assets/styles/content.less | 8 ++ resources/assets/styles/forms.less | 52 +++++++++++++ 13 files changed, 332 insertions(+), 35 deletions(-) diff --git a/app/Http/Controllers/Api/Web/NotificationsController.php b/app/Http/Controllers/Api/Web/NotificationsController.php index badec332..5843b3b3 100644 --- a/app/Http/Controllers/Api/Web/NotificationsController.php +++ b/app/Http/Controllers/Api/Web/NotificationsController.php @@ -25,6 +25,8 @@ use Input; use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase; use Poniverse\Ponyfm\Models\Notification; use Poniverse\Ponyfm\Models\Subscription; +use Poniverse\Ponyfm\Models\Track; +use Poniverse\Ponyfm\Models\User; use Minishlink\WebPush\WebPush; class NotificationsController extends ApiControllerBase @@ -84,9 +86,25 @@ class NotificationsController extends ApiControllerBase 'auth' => $input->keys->auth ]); - return $subscription->id; + return ['id' => $subscription->id]; } else { - return $existing->id; + return ['id' => $existing->id]; } } + + /** + * Removes a user's notification subscription + * + * @return string + */ + public function postUnsubscribe() + { + $input = json_decode(Input::json('subscription')); + + $existing = Subscription::where('endpoint', '=', $input->endpoint) + ->where('user_id', '=', Auth::user()->id) + ->delete(); + + return ['result' => 'success']; + } } diff --git a/app/Http/routes.php b/app/Http/routes.php index a59b82bc..a7e09d50 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -138,6 +138,7 @@ Route::group(['prefix' => 'api/web'], function() { Route::get('/notifications', 'Api\Web\NotificationsController@getNotifications'); Route::put('/notifications/mark-as-read', 'Api\Web\NotificationsController@putMarkAsRead'); Route::post('/notifications/subscribe', 'Api\Web\NotificationsController@postSubscribe'); + Route::post('/notifications/unsubscribe', 'Api\Web\NotificationsController@postUnsubscribe'); Route::get('/tracks/edit/{id}', 'Api\Web\TracksController@getEdit'); diff --git a/app/Jobs/SendNotifications.php b/app/Jobs/SendNotifications.php index 53d1080d..9d71b30e 100644 --- a/app/Jobs/SendNotifications.php +++ b/app/Jobs/SendNotifications.php @@ -62,7 +62,7 @@ class SendNotifications extends Job implements SelfHandling, ShouldQueue // tries (and fails) to serialize static fields. $drivers = [ PonyfmDriver::class, - //NativeDriver::class + NativeDriver::class ]; foreach ($drivers as $driver) { diff --git a/app/Library/Notifications/Drivers/NativeDriver.php b/app/Library/Notifications/Drivers/NativeDriver.php index 4243a4a0..1690d402 100644 --- a/app/Library/Notifications/Drivers/NativeDriver.php +++ b/app/Library/Notifications/Drivers/NativeDriver.php @@ -21,31 +21,48 @@ namespace Poniverse\Ponyfm\Library\Notifications\Drivers; -use Carbon\Carbon; use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Models\Activity; use Poniverse\Ponyfm\Models\Comment; -use Poniverse\Ponyfm\Models\Notification; use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\User; +use Minishlink\WebPush\WebPush; -class NativeDriver extends AbstractDriver { +class NativeDriver extends AbstractDriver { /** - * A helper method for bulk insertion of notification records. + * Method for sending notifications to devices * - * @param int $activityId + * @param Activity $activity * @param User[] $recipients collection of {@link User} objects */ - private function pushNotifications(int $activityId, $recipients) { - $notifications = []; + private function pushNotifications(Activity $activity, $recipients) { + $apiKeys = array( + 'GCM' => 'AIzaSyCLmCVIgASWL280rHyPz8OP7il3pf8SrGg', + ); + + $webPush = new WebPush($apiKeys); + + $data = [ + 'id' => $activity->id, + 'text' => $activity->getTextAttribute(), + 'title' => $activity->getTitleFromActivityType(), + 'image' => $activity->getThumbnailUrlAttribute(), + 'url' => $activity->url + ]; + + $jsonData = json_encode($data); + foreach ($recipients as $recipient) { - $notifications[] = [ - 'activity_id' => $activityId, - 'user_id' => $recipient->id - ]; + $webPush->sendNotification( + $recipient->endpoint, + $jsonData, + $recipient->p256dh, + $recipient->auth + ); } - Notification::insert($notifications); + + $webPush->flush(); } /** @@ -58,7 +75,7 @@ class NativeDriver extends AbstractDriver { ->get()[0]; - $this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); } /** @@ -70,16 +87,16 @@ class NativeDriver extends AbstractDriver { ->where('resource_id', $playlist->id) ->get()[0]; - $this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); } public function newFollower(User $userBeingFollowed, User $follower) { - $activity = Activity::where('user_id', $follower->user_id) + $activity = Activity::where('user_id', $follower->id) ->where('activity_type', Activity::TYPE_NEW_FOLLOWER) ->where('resource_id', $userBeingFollowed->id) ->get()[0]; - $this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); } /** @@ -91,18 +108,18 @@ class NativeDriver extends AbstractDriver { ->where('resource_id', $comment->id) ->get()[0]; - $this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); } /** * @inheritdoc */ public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) { - $activity = Activity::where('user_id', $favouriter->user_id) + $activity = Activity::where('user_id', $favouriter->id) ->where('activity_type', Activity::TYPE_CONTENT_FAVOURITED) ->where('resource_id', $entityBeingFavourited->id) ->get()[0]; - $this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); } } diff --git a/app/Library/Notifications/RecipientFinder.php b/app/Library/Notifications/RecipientFinder.php index e92aef7c..94f8e022 100644 --- a/app/Library/Notifications/RecipientFinder.php +++ b/app/Library/Notifications/RecipientFinder.php @@ -25,9 +25,11 @@ use Illuminate\Foundation\Bus\DispatchesJobs; use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Contracts\NotificationHandler; use Poniverse\Ponyfm\Jobs\SendNotifications; +use Poniverse\Ponyfm\Library\Notifications\Drivers\NativeDriver; use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver; use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Playlist; +use Poniverse\Ponyfm\Models\Subscription; use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\User; @@ -59,6 +61,21 @@ class RecipientFinder implements NotificationHandler { switch ($this->notificationDriver) { case PonyfmDriver::class: return $track->user->followers; + case NativeDriver::class: + $followerIds = []; + $subIds = []; + $rawSubIds = Subscription::select('id')->get(); + + foreach ($track->user->followers as $follower) { + array_push($followerIds, $follower->id); + } + + foreach ($rawSubIds as $sub) { + array_push($subIds, $sub->id); + } + + $targetIds = array_intersect($followerIds, $subIds); + return Subscription::whereIn('user_id', $targetIds)->get(); default: return $this->fail(); } @@ -71,6 +88,21 @@ class RecipientFinder implements NotificationHandler { switch ($this->notificationDriver) { case PonyfmDriver::class: return $playlist->user->followers; + case NativeDriver::class: + $followerIds = []; + $subIds = []; + $rawSubIds = Subscription::select('id')->get(); + + foreach ($playlist->user->followers as $follower) { + array_push($followerIds, $follower->id); + } + + foreach ($rawSubIds as $sub) { + array_push($subIds, $sub->id); + } + + $targetIds = array_intersect($followerIds, $subIds); + return Subscription::whereIn('user_id', $targetIds)->get(); default: return $this->fail(); } @@ -83,6 +115,8 @@ class RecipientFinder implements NotificationHandler { switch ($this->notificationDriver) { case PonyfmDriver::class: return [$userBeingFollowed]; + case NativeDriver::class: + return Subscription::where('user_id', '=', $userBeingFollowed->id)->get(); default: return $this->fail(); } @@ -98,6 +132,8 @@ class RecipientFinder implements NotificationHandler { $comment->user->id === $comment->resource->user->id ? [] : [$comment->resource->user]; + case NativeDriver::class: + return Subscription::where('user_id', '=', $comment->resource->user->id)->get(); default: return $this->fail(); } @@ -113,6 +149,8 @@ class RecipientFinder implements NotificationHandler { $favouriter->id === $entityBeingFavourited->user->id ? [] : [$entityBeingFavourited->user]; + case NativeDriver::class: + return Subscription::where('user_id', '=', $entityBeingFavourited->user->id)->get(); default: return $this->fail(); } diff --git a/app/Models/Activity.php b/app/Models/Activity.php index fdb91215..5e661d36 100644 --- a/app/Models/Activity.php +++ b/app/Models/Activity.php @@ -165,6 +165,24 @@ class Activity extends Model { } } + public function getTitleFromActivityType() { + switch($this->activity_type) { + case static::TYPE_PUBLISHED_TRACK: + return "Pony.fm - New track"; + case static::TYPE_PUBLISHED_PLAYLIST: + return "Pony.fm - New playlist"; + case static::TYPE_NEW_FOLLOWER: + return "Pony.fm - New follower"; + case static::TYPE_NEW_COMMENT: + return "Pony.fm - New comment"; + case static::TYPE_CONTENT_FAVOURITED: + return "Pony.fm - Favourited"; + + default: + return "Pony.fm - Unknown"; + } + } + /** * @return string human-readable Markdown string describing this notification * @throws \Exception diff --git a/public/service-worker.js b/public/service-worker.js index dd410e97..2d3a8f0f 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -23,6 +23,14 @@ var urlsToCache = [ var CACHE_NAME = 'pfm-offline-v1'; +var notifUrlCache = {}; + +function getChromeVersion () { + var raw = navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./); + + return raw ? parseInt(raw[2], 10) : false; +} + // Set the callback for the install step self.addEventListener('install', function(event) { // Perform install steps @@ -60,5 +68,41 @@ self.addEventListener('fetch', function(event) { }); self.addEventListener('push', function(event) { - console.log('Push message', event); + console.log(event); + var data = {}; + + if (event.data) { + console.log(event.data.json()); + data = JSON.parse(event.data.text()); + } + + notifUrlCache['pfm-' + data.id] = data.url; + + self.registration.showNotification(data.title, { + body: data.text, + icon: data.image, + tag: 'pfm-' + data.id + }) +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + + event.waitUntil( + clients.matchAll({ + type: "window" + }) + .then(function(clientList) { + var url = notifUrlCache[event.notification.tag]; + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if (client.url == url && 'focus' in client) + return client.focus(); + } + + if (clients.openWindow) { + return clients.openWindow(url); + } + }) + ); }); \ No newline at end of file diff --git a/public/templates/directives/notification-list.html b/public/templates/directives/notification-list.html index 036b2352..1dac7b41 100644 --- a/public/templates/directives/notification-list.html +++ b/public/templates/directives/notification-list.html @@ -1,7 +1,14 @@
-
- -

{{ ::notification.text }}

+
+ + + Enable push notifications +
+
+ +

No notifications :(

-

No notifications :(

\ No newline at end of file diff --git a/resources/assets/scripts/app/controllers/application.coffee b/resources/assets/scripts/app/controllers/application.coffee index bbed7738..c5e6cf37 100644 --- a/resources/assets/scripts/app/controllers/application.coffee +++ b/resources/assets/scripts/app/controllers/application.coffee @@ -31,7 +31,6 @@ module.exports = angular.module('ponyfm').controller "application", [ console.log 'Service Worker is supported' navigator.serviceWorker.register('service-worker.js').then((reg) -> console.log 'SW registered', reg - ).catch (err) -> console.log 'SW register failed', err diff --git a/resources/assets/scripts/app/directives/notification-list.coffee b/resources/assets/scripts/app/directives/notification-list.coffee index 0edfe4d9..45d0fa57 100644 --- a/resources/assets/scripts/app/directives/notification-list.coffee +++ b/resources/assets/scripts/app/directives/notification-list.coffee @@ -24,21 +24,43 @@ module.exports = angular.module('ponyfm').directive 'pfmNotificationList', () -> '$scope', 'notifications', '$timeout', '$rootScope', '$http' ($scope, notifications, $timeout, $rootScope, $http) -> $scope.notifications = [] + $scope.subscribed = false + $scope.switchDisabled = true + $scope.switchHidden = false isTimeoutScheduled = false # TODO: ADD REFRESH BUTTON $rootScope.$on 'shouldUpdateNotifications', () -> refreshNotifications() + + $scope.switchToggled = () -> + if $scope.subscribed + $scope.switchDisabled = true + notifications.subscribe().done (result) -> + if result + $scope.switchDisabled = false + else + $scope.switchDisabled = true + notifications.unsubscribe().done (result) -> + if result + $scope.switchDisabled = false + checkSubscription = () -> - navigator.serviceWorker.ready.then((reg) -> - reg.pushManager.subscribe({userVisibleOnly: true}).then((sub) -> - console.log 'Push sub', JSON.stringify(sub) - subData = JSON.stringify(sub) - $http.post('/api/web/notifications/subscribe', {subscription: subData}) - ) - ) + $scope.disabled = true + notifications.checkSubscription().done (subStatus) -> + switch subStatus + when 0 + $scope.subscribed = false + $scope.switchDisabled = false + when 1 + $scope.subscribed = true + $scope.switchDisabled = false + else + $scope.subscribed = false + $scope.switchDisabled = true + $scope.hidden = true refreshNotifications = () -> notifications.getNotifications().done (result) -> diff --git a/resources/assets/scripts/app/services/notifications.coffee b/resources/assets/scripts/app/services/notifications.coffee index 7fa2ad8b..0a10bb94 100644 --- a/resources/assets/scripts/app/services/notifications.coffee +++ b/resources/assets/scripts/app/services/notifications.coffee @@ -61,5 +61,78 @@ module.exports = angular.module('ponyfm').factory('notifications', [ else return 0 + subscribe: () -> + def = new $.Deferred() + navigator.serviceWorker.ready.then (reg) -> + reg.pushManager.subscribe({userVisibleOnly: true}).then (sub) -> + console.log 'Push sub', JSON.stringify(sub) + self.sendSubscriptionToServer(sub).done (result) -> + def.resolve result + + def.promise() + + unsubscribe: () -> + def = new $.Deferred() + navigator.serviceWorker.ready.then (reg) -> + reg.pushManager.getSubscription().then (sub) -> + sub.unsubscribe().then (result) -> + self.removeSubscriptionFromServer(sub).done (result) -> + def.resolve true + .catch (e) -> + console.warn('Unsubscription error: ', e) + def.resolve false + + def.promise() + + sendSubscriptionToServer: (sub) -> + def = new $.Deferred() + subData = JSON.stringify(sub) + $http.post('/api/web/notifications/subscribe', {subscription: subData}).success () -> + def.resolve true + .error () -> + def.resolve false + + def.promise() + + removeSubscriptionFromServer: (sub) -> + def = new $.Deferred() + subData = JSON.stringify(sub) + $http.post('/api/web/notifications/unsubscribe', {subscription: subData}).success () -> + def.resolve true + .error () -> + def.resolve false + + def.promise() + + checkSubscription: () -> + def = new $.Deferred() + if 'serviceWorker' of navigator + if !('showNotification' of ServiceWorkerRegistration.prototype) + console.warn('Notifications aren\'t supported.') + def.resolve -1 + + if Notification.permission == 'denied' + console.warn('The user has blocked notifications.') + def.resolve -1 + + if !('PushManager' of window) + console.warn('Push messaging isn\'t supported.') + def.resolve -1 + + navigator.serviceWorker.ready.then (reg) -> + reg.pushManager.getSubscription().then (sub) -> + if !sub + def.resolve 0 + + self.sendSubscriptionToServer(sub) + + def.resolve 1 + + + else + console.warn('Service worker isn\'t supported.') + def.resolve -1 + + def.promise() self ]) diff --git a/resources/assets/styles/content.less b/resources/assets/styles/content.less index 8afa958d..982a33fa 100644 --- a/resources/assets/styles/content.less +++ b/resources/assets/styles/content.less @@ -626,6 +626,14 @@ html { } } +.notif-switch { + margin-bottom: 15px; + + span { + margin-left: 5px; + } +} + .notif-list { .error { text-align: center; diff --git a/resources/assets/styles/forms.less b/resources/assets/styles/forms.less index b1f2a86b..db34eed0 100644 --- a/resources/assets/styles/forms.less +++ b/resources/assets/styles/forms.less @@ -121,3 +121,55 @@ label { textarea { height: 60px; } + +.switch { + display: inline-block; + position: relative; + width: 40px; + height: 16px; + border-radius: 8px; + background: rgba(0,0,0,0.26); + -webkit-transition: background 0.28s cubic-bezier(0.4, 0, 0.2, 1); + transition: background 0.28s cubic-bezier(0.4, 0, 0.2, 1); + vertical-align: middle; + cursor: pointer; + + &::before { + content: ''; + position: absolute; + top: -4px; + left: -4px; + width: 24px; + height: 24px; + background: #fafafa; + box-shadow: 0 2px 8px rgba(0,0,0,0.28); + border-radius: 50%; + transition: left 0.28s cubic-bezier(0.4, 0, 0.2, 1), background 0.28s cubic-bezier(0.4, 0, 0.2, 1), box-shadow 0.28s cubic-bezier(0.4, 0, 0.2, 1); + } + + &:active::before { + box-shadow: 0 2px 8px rgba(0,0,0,0.28), 0 0 0 20px rgba(128,128,128,0.1); + } +} + +input:checked + .switch { + background: rgba(132,82,138,0.5); +} + +input:checked + .switch::before { + left: 20px; + background: #84528a; +} + +input:checked + .switch:active::before { + box-shadow: 0 2px 8px rgba(0,0,0,0.28), 0 0 0 20px rgba(0,150,136,0.2); +} + +.switch.disabled { + background: #bfbfbf; +} + +.switch.disabled::before { + background: #dadada; +} +