#25: Basic service worker notifications

This commit is contained in:
Josef Citrine 2016-06-12 00:58:10 +01:00
parent 2b72b4dcdb
commit 61520815de
13 changed files with 332 additions and 35 deletions

View file

@ -25,6 +25,8 @@ use Input;
use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase; use Poniverse\Ponyfm\Http\Controllers\ApiControllerBase;
use Poniverse\Ponyfm\Models\Notification; use Poniverse\Ponyfm\Models\Notification;
use Poniverse\Ponyfm\Models\Subscription; use Poniverse\Ponyfm\Models\Subscription;
use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User;
use Minishlink\WebPush\WebPush; use Minishlink\WebPush\WebPush;
class NotificationsController extends ApiControllerBase class NotificationsController extends ApiControllerBase
@ -84,9 +86,25 @@ class NotificationsController extends ApiControllerBase
'auth' => $input->keys->auth 'auth' => $input->keys->auth
]); ]);
return $subscription->id; return ['id' => $subscription->id];
} else { } 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'];
}
} }

View file

@ -138,6 +138,7 @@ Route::group(['prefix' => 'api/web'], function() {
Route::get('/notifications', 'Api\Web\NotificationsController@getNotifications'); Route::get('/notifications', 'Api\Web\NotificationsController@getNotifications');
Route::put('/notifications/mark-as-read', 'Api\Web\NotificationsController@putMarkAsRead'); Route::put('/notifications/mark-as-read', 'Api\Web\NotificationsController@putMarkAsRead');
Route::post('/notifications/subscribe', 'Api\Web\NotificationsController@postSubscribe'); 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'); Route::get('/tracks/edit/{id}', 'Api\Web\TracksController@getEdit');

View file

@ -62,7 +62,7 @@ class SendNotifications extends Job implements SelfHandling, ShouldQueue
// tries (and fails) to serialize static fields. // tries (and fails) to serialize static fields.
$drivers = [ $drivers = [
PonyfmDriver::class, PonyfmDriver::class,
//NativeDriver::class NativeDriver::class
]; ];
foreach ($drivers as $driver) { foreach ($drivers as $driver) {

View file

@ -21,31 +21,48 @@
namespace Poniverse\Ponyfm\Library\Notifications\Drivers; namespace Poniverse\Ponyfm\Library\Notifications\Drivers;
use Carbon\Carbon;
use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Models\Activity; use Poniverse\Ponyfm\Models\Activity;
use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Notification;
use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User; 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 * @param User[] $recipients collection of {@link User} objects
*/ */
private function pushNotifications(int $activityId, $recipients) { private function pushNotifications(Activity $activity, $recipients) {
$notifications = []; $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) { foreach ($recipients as $recipient) {
$notifications[] = [ $webPush->sendNotification(
'activity_id' => $activityId, $recipient->endpoint,
'user_id' => $recipient->id $jsonData,
]; $recipient->p256dh,
$recipient->auth
);
} }
Notification::insert($notifications);
$webPush->flush();
} }
/** /**
@ -58,7 +75,7 @@ class NativeDriver extends AbstractDriver {
->get()[0]; ->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) ->where('resource_id', $playlist->id)
->get()[0]; ->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) { 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('activity_type', Activity::TYPE_NEW_FOLLOWER)
->where('resource_id', $userBeingFollowed->id) ->where('resource_id', $userBeingFollowed->id)
->get()[0]; ->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) ->where('resource_id', $comment->id)
->get()[0]; ->get()[0];
$this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args()));
} }
/** /**
* @inheritdoc * @inheritdoc
*/ */
public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) { 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('activity_type', Activity::TYPE_CONTENT_FAVOURITED)
->where('resource_id', $entityBeingFavourited->id) ->where('resource_id', $entityBeingFavourited->id)
->get()[0]; ->get()[0];
$this->pushNotifications($activity->id, $this->getRecipients(__FUNCTION__, func_get_args())); $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args()));
} }
} }

View file

@ -25,9 +25,11 @@ use Illuminate\Foundation\Bus\DispatchesJobs;
use Poniverse\Ponyfm\Contracts\Favouritable; use Poniverse\Ponyfm\Contracts\Favouritable;
use Poniverse\Ponyfm\Contracts\NotificationHandler; use Poniverse\Ponyfm\Contracts\NotificationHandler;
use Poniverse\Ponyfm\Jobs\SendNotifications; use Poniverse\Ponyfm\Jobs\SendNotifications;
use Poniverse\Ponyfm\Library\Notifications\Drivers\NativeDriver;
use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver; use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver;
use Poniverse\Ponyfm\Models\Comment; use Poniverse\Ponyfm\Models\Comment;
use Poniverse\Ponyfm\Models\Playlist; use Poniverse\Ponyfm\Models\Playlist;
use Poniverse\Ponyfm\Models\Subscription;
use Poniverse\Ponyfm\Models\Track; use Poniverse\Ponyfm\Models\Track;
use Poniverse\Ponyfm\Models\User; use Poniverse\Ponyfm\Models\User;
@ -59,6 +61,21 @@ class RecipientFinder implements NotificationHandler {
switch ($this->notificationDriver) { switch ($this->notificationDriver) {
case PonyfmDriver::class: case PonyfmDriver::class:
return $track->user->followers; 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: default:
return $this->fail(); return $this->fail();
} }
@ -71,6 +88,21 @@ class RecipientFinder implements NotificationHandler {
switch ($this->notificationDriver) { switch ($this->notificationDriver) {
case PonyfmDriver::class: case PonyfmDriver::class:
return $playlist->user->followers; 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: default:
return $this->fail(); return $this->fail();
} }
@ -83,6 +115,8 @@ class RecipientFinder implements NotificationHandler {
switch ($this->notificationDriver) { switch ($this->notificationDriver) {
case PonyfmDriver::class: case PonyfmDriver::class:
return [$userBeingFollowed]; return [$userBeingFollowed];
case NativeDriver::class:
return Subscription::where('user_id', '=', $userBeingFollowed->id)->get();
default: default:
return $this->fail(); return $this->fail();
} }
@ -98,6 +132,8 @@ class RecipientFinder implements NotificationHandler {
$comment->user->id === $comment->resource->user->id $comment->user->id === $comment->resource->user->id
? [] ? []
: [$comment->resource->user]; : [$comment->resource->user];
case NativeDriver::class:
return Subscription::where('user_id', '=', $comment->resource->user->id)->get();
default: default:
return $this->fail(); return $this->fail();
} }
@ -113,6 +149,8 @@ class RecipientFinder implements NotificationHandler {
$favouriter->id === $entityBeingFavourited->user->id $favouriter->id === $entityBeingFavourited->user->id
? [] ? []
: [$entityBeingFavourited->user]; : [$entityBeingFavourited->user];
case NativeDriver::class:
return Subscription::where('user_id', '=', $entityBeingFavourited->user->id)->get();
default: default:
return $this->fail(); return $this->fail();
} }

View file

@ -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 * @return string human-readable Markdown string describing this notification
* @throws \Exception * @throws \Exception

View file

@ -23,6 +23,14 @@ var urlsToCache = [
var CACHE_NAME = 'pfm-offline-v1'; 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 // Set the callback for the install step
self.addEventListener('install', function(event) { self.addEventListener('install', function(event) {
// Perform install steps // Perform install steps
@ -60,5 +68,41 @@ self.addEventListener('fetch', function(event) {
}); });
self.addEventListener('push', 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);
}
})
);
}); });

View file

@ -1,7 +1,14 @@
<div class="notif-list"> <div class="notif-list">
<div ng-repeat="notification in notifications" class="notification" ng-class="{'unread': !notification.is_read}"> <div class="notif-switch" ng-hide="switchHidden">
<a href="{{ ::notification.url }}" class="img-link"><img pfm-src-loader="::notification.thumbnail_url" pfm-src-size="thumbnail"></a> <input id="test" type="checkbox" hidden="hidden" ng-model="subscribed" ng-change="switchToggled()" ng-disabled="switchDisabled"/>
<a href="{{ ::notification.url }}" class="message"><p>{{ ::notification.text }}</p></a> <label for="test" class="switch" ng-class="{'disabled': switchDisabled}"></label>
<span>Enable push notifications</span>
</div>
<div class="notif-scroll">
<div ng-repeat="notification in notifications" class="notification" ng-class="{'unread': !notification.is_read}">
<a href="{{ ::notification.url }}" class="img-link"><img pfm-src-loader="::notification.thumbnail_url" pfm-src-size="thumbnail"></a>
<a href="{{ ::notification.url }}" class="message"><p>{{ ::notification.text }}</p></a>
</div>
<p ng-show="notifications.length < 1" class="error">No notifications :(</p>
</div> </div>
<p ng-show="notifications.length < 1" class="error">No notifications :(</p>
</div> </div>

View file

@ -31,7 +31,6 @@ module.exports = angular.module('ponyfm').controller "application", [
console.log 'Service Worker is supported' console.log 'Service Worker is supported'
navigator.serviceWorker.register('service-worker.js').then((reg) -> navigator.serviceWorker.register('service-worker.js').then((reg) ->
console.log 'SW registered', reg console.log 'SW registered', reg
).catch (err) -> ).catch (err) ->
console.log 'SW register failed', err console.log 'SW register failed', err

View file

@ -24,21 +24,43 @@ module.exports = angular.module('ponyfm').directive 'pfmNotificationList', () ->
'$scope', 'notifications', '$timeout', '$rootScope', '$http' '$scope', 'notifications', '$timeout', '$rootScope', '$http'
($scope, notifications, $timeout, $rootScope, $http) -> ($scope, notifications, $timeout, $rootScope, $http) ->
$scope.notifications = [] $scope.notifications = []
$scope.subscribed = false
$scope.switchDisabled = true
$scope.switchHidden = false
isTimeoutScheduled = false isTimeoutScheduled = false
# TODO: ADD REFRESH BUTTON # TODO: ADD REFRESH BUTTON
$rootScope.$on 'shouldUpdateNotifications', () -> $rootScope.$on 'shouldUpdateNotifications', () ->
refreshNotifications() 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 = () -> checkSubscription = () ->
navigator.serviceWorker.ready.then((reg) -> $scope.disabled = true
reg.pushManager.subscribe({userVisibleOnly: true}).then((sub) -> notifications.checkSubscription().done (subStatus) ->
console.log 'Push sub', JSON.stringify(sub) switch subStatus
subData = JSON.stringify(sub) when 0
$http.post('/api/web/notifications/subscribe', {subscription: subData}) $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 = () -> refreshNotifications = () ->
notifications.getNotifications().done (result) -> notifications.getNotifications().done (result) ->

View file

@ -61,5 +61,78 @@ module.exports = angular.module('ponyfm').factory('notifications', [
else else
return 0 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 self
]) ])

View file

@ -626,6 +626,14 @@ html {
} }
} }
.notif-switch {
margin-bottom: 15px;
span {
margin-left: 5px;
}
}
.notif-list { .notif-list {
.error { .error {
text-align: center; text-align: center;

View file

@ -121,3 +121,55 @@ label {
textarea { textarea {
height: 60px; 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;
}