mirror of
https://github.com/Poniverse/Pony.fm.git
synced 2024-11-22 04:58:01 +01:00
#25: Basic service worker notifications
This commit is contained in:
parent
2b72b4dcdb
commit
61520815de
13 changed files with 332 additions and 35 deletions
|
@ -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'];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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 {
|
||||
/**
|
||||
* 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()));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
})
|
||||
);
|
||||
});
|
|
@ -1,7 +1,14 @@
|
|||
<div class="notif-list">
|
||||
<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 class="notif-switch" ng-hide="switchHidden">
|
||||
<input id="test" type="checkbox" hidden="hidden" ng-model="subscribed" ng-change="switchToggled()" ng-disabled="switchDisabled"/>
|
||||
<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>
|
||||
<p ng-show="notifications.length < 1" class="error">No notifications :(</p>
|
||||
</div>
|
|
@ -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
|
||||
|
||||
|
|
|
@ -24,6 +24,9 @@ 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
|
||||
|
@ -31,14 +34,33 @@ module.exports = angular.module('ponyfm').directive 'pfmNotificationList', () ->
|
|||
$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) ->
|
||||
|
|
|
@ -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
|
||||
])
|
||||
|
|
8
resources/assets/styles/content.less
vendored
8
resources/assets/styles/content.less
vendored
|
@ -626,6 +626,14 @@ html {
|
|||
}
|
||||
}
|
||||
|
||||
.notif-switch {
|
||||
margin-bottom: 15px;
|
||||
|
||||
span {
|
||||
margin-left: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.notif-list {
|
||||
.error {
|
||||
text-align: center;
|
||||
|
|
52
resources/assets/styles/forms.less
vendored
52
resources/assets/styles/forms.less
vendored
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in a new issue