diff --git a/app/Http/Controllers/Api/Web/NotificationsController.php b/app/Http/Controllers/Api/Web/NotificationsController.php index c0a0d710..10a25a37 100644 --- a/app/Http/Controllers/Api/Web/NotificationsController.php +++ b/app/Http/Controllers/Api/Web/NotificationsController.php @@ -21,10 +21,13 @@ namespace Poniverse\Ponyfm\Http\Controllers\Api\Web; use Auth; -use Carbon\Carbon; 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 { @@ -59,4 +62,52 @@ class NotificationsController extends ApiControllerBase return ['notifications_updated' => $numberOfUpdatedRows]; } + + /** + * Subscribe a user to native push notifications. Takes an endpoint and + * encryption keys from the client and stores them in the database + * for future use. + * + * @return string + */ + public function postSubscribe() + { + $input = json_decode(Input::json('subscription')); + if ($input != 'null') { + $existing = Subscription::where('endpoint', '=', $input->endpoint) + ->where('user_id', '=', Auth::user()->id) + ->first(); + + if ($existing === null) { + $subscription = Subscription::create([ + 'user_id' => Auth::user()->id, + 'endpoint' => $input->endpoint, + 'p256dh' => $input->keys->p256dh, + 'auth' => $input->keys->auth + ]); + + return ['id' => $subscription->id]; + } else { + return ['id' => $existing->id]; + } + } else { + return ['error' => 'No data']; + } + } + + /** + * 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 976c50ee..a7e09d50 100644 --- a/app/Http/routes.php +++ b/app/Http/routes.php @@ -134,8 +134,11 @@ Route::group(['prefix' => 'api/web'], function() { Route::post('/dashboard/read-news', 'Api\Web\DashboardController@postReadNews'); Route::get('/account/settings/{slug}', 'Api\Web\AccountController@getSettings'); + 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 f586834e..9d71b30e 100644 --- a/app/Jobs/SendNotifications.php +++ b/app/Jobs/SendNotifications.php @@ -25,6 +25,7 @@ use Illuminate\Queue\InteractsWithQueue; use Poniverse\Ponyfm\Jobs\Job; use Illuminate\Contracts\Bus\SelfHandling; use Poniverse\Ponyfm\Library\Notifications\Drivers\AbstractDriver; +use Poniverse\Ponyfm\Library\Notifications\Drivers\NativeDriver; use Poniverse\Ponyfm\Library\Notifications\Drivers\PonyfmDriver; use Poniverse\Ponyfm\Models\User; use SerializesModels; @@ -60,7 +61,8 @@ class SendNotifications extends Job implements SelfHandling, ShouldQueue // to work around a Laravel bug - namely, the SerializesModels trait // tries (and fails) to serialize static fields. $drivers = [ - PonyfmDriver::class + PonyfmDriver::class, + NativeDriver::class ]; foreach ($drivers as $driver) { diff --git a/app/Library/Notifications/Drivers/NativeDriver.php b/app/Library/Notifications/Drivers/NativeDriver.php new file mode 100644 index 00000000..c340b5e9 --- /dev/null +++ b/app/Library/Notifications/Drivers/NativeDriver.php @@ -0,0 +1,129 @@ +. + */ + +namespace Poniverse\Ponyfm\Library\Notifications\Drivers; + + +use Config; +use Poniverse\Ponyfm\Contracts\Favouritable; +use Poniverse\Ponyfm\Models\Activity; +use Poniverse\Ponyfm\Models\Comment; +use Poniverse\Ponyfm\Models\Playlist; +use Poniverse\Ponyfm\Models\Track; +use Poniverse\Ponyfm\Models\User; +use Minishlink\WebPush\WebPush; + +class NativeDriver extends AbstractDriver { + /** + * Method for sending notifications to devices + * + * @param Activity $activity + * @param User[] $recipients collection of {@link User} objects + */ + private function pushNotifications(Activity $activity, $recipients) + { + if (Config::get('ponyfm.gcm_key') != 'default') { + $apiKeys = array( + 'GCM' => Config::get('ponyfm.gcm_key'), + ); + + $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) { + $webPush->sendNotification( + $recipient->endpoint, + $jsonData, + $recipient->p256dh, + $recipient->auth + ); + } + + $webPush->flush(); + } + } + + /** + * @inheritdoc + */ + public function publishedNewTrack(Track $track) { + $activity = Activity::where('user_id', $track->user_id) + ->where('activity_type', Activity::TYPE_PUBLISHED_TRACK) + ->where('resource_id', $track->id) + ->get()[0]; + + + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); + } + + /** + * @inheritdoc + */ + public function publishedNewPlaylist(Playlist $playlist) { + $activity = Activity::where('user_id', $playlist->user_id) + ->where('activity_type', Activity::TYPE_PUBLISHED_PLAYLIST) + ->where('resource_id', $playlist->id) + ->get()[0]; + + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); + } + + public function newFollower(User $userBeingFollowed, User $follower) { + $activity = Activity::where('user_id', $follower->id) + ->where('activity_type', Activity::TYPE_NEW_FOLLOWER) + ->where('resource_id', $userBeingFollowed->id) + ->get()[0]; + + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); + } + + /** + * @inheritdoc + */ + public function newComment(Comment $comment) { + $activity = Activity::where('user_id', $comment->user_id) + ->where('activity_type', Activity::TYPE_NEW_COMMENT) + ->where('resource_id', $comment->id) + ->get()[0]; + + $this->pushNotifications($activity, $this->getRecipients(__FUNCTION__, func_get_args())); + } + + /** + * @inheritdoc + */ + public function newFavourite(Favouritable $entityBeingFavourited, User $favouriter) { + $activity = Activity::where('user_id', $favouriter->id) + ->where('activity_type', Activity::TYPE_CONTENT_FAVOURITED) + ->where('resource_id', $entityBeingFavourited->id) + ->get()[0]; + + $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/app/Models/Subscription.php b/app/Models/Subscription.php new file mode 100644 index 00000000..eaffdd76 --- /dev/null +++ b/app/Models/Subscription.php @@ -0,0 +1,49 @@ +. + */ + +namespace Poniverse\Ponyfm\Models; + +use Illuminate\Database\Eloquent\Model; + +/** + * Poniverse\Ponyfm\Models\Subscription + * + * @property integer $id + * @property integer $user_id + * @property string $endpoint + * @property string $p256dh + * @property string $auth + * @property-read \Poniverse\Ponyfm\Models\User $user + */ +class Subscription extends Model { + public $timestamps = false; + protected $fillable = ['user_id', 'endpoint', 'p256dh', 'auth']; + protected $casts = [ + 'id' => 'integer', + 'user_id' => 'integer', + 'endpoint' => 'string', + 'p256dh' => 'string', + 'auth' => 'string' + ]; + + public function user() { + return $this->belongsTo(User::class, 'user_id', 'id'); + } +} diff --git a/composer.json b/composer.json index f8fcedae..e1a15e3c 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "barryvdh/laravel-debugbar": "^2.2", "predis/predis": "^1.0", "ksubileau/color-thief-php": "^1.3", - "graham-campbell/exceptions": "^8.6" + "graham-campbell/exceptions": "^8.6", + "minishlink/web-push": "^1.0" }, "require-dev": { "fzaninotto/faker": "~1.4", diff --git a/composer.lock b/composer.lock index da69a173..49ff6f90 100644 --- a/composer.lock +++ b/composer.lock @@ -4,8 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file", "This file is @generated automatically" ], - "hash": "5c9f86045c835b8964e1eb198a7727ea", - "content-hash": "a09f15b4c222efb8e676a4efdd979b4f", + "hash": "070553e4e21387213808a4cb779e5f16", + "content-hash": "98c97b7ca37abf031e353edaf4ac2ae3", "packages": [ { "name": "barryvdh/laravel-debugbar", @@ -124,6 +124,59 @@ ], "time": "2016-03-03 08:45:00" }, + { + "name": "beberlei/assert", + "version": "v2.5", + "source": { + "type": "git", + "url": "https://github.com/beberlei/assert.git", + "reference": "91e2690c4ecc8a4e3e2d333430069f6a0c694a7a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/beberlei/assert/zipball/91e2690c4ecc8a4e3e2d333430069f6a0c694a7a", + "reference": "91e2690c4ecc8a4e3e2d333430069f6a0c694a7a", + "shasum": "" + }, + "require": { + "ext-mbstring": "*", + "php": ">=5.3" + }, + "require-dev": { + "phpunit/phpunit": "@stable" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.3-dev" + } + }, + "autoload": { + "psr-0": { + "Assert": "lib/" + }, + "files": [ + "lib/Assert/functions.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "BSD-2-Clause" + ], + "authors": [ + { + "name": "Benjamin Eberlei", + "email": "kontakt@beberlei.de" + } + ], + "description": "Thin assertion library for input validation in business models.", + "keywords": [ + "assert", + "assertion", + "validation" + ], + "time": "2016-03-22 14:34:51" + }, { "name": "classpreloader/classpreloader", "version": "3.0.0", @@ -883,6 +936,58 @@ ], "time": "2016-03-18 16:31:37" }, + { + "name": "fgrosse/phpasn1", + "version": "1.3.2", + "source": { + "type": "git", + "url": "https://github.com/fgrosse/PHPASN1.git", + "reference": "ee6d1abd18f8bcbaf0b55563ba87e5ed16cd0c98" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/fgrosse/PHPASN1/zipball/ee6d1abd18f8bcbaf0b55563ba87e5ed16cd0c98", + "reference": "ee6d1abd18f8bcbaf0b55563ba87e5ed16cd0c98", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.5" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.3.x-dev" + } + }, + "autoload": { + "psr-4": { + "FG\\": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Friedrich Große", + "email": "friedrich.grosse@gmail.com", + "homepage": "https://github.com/FGrosse", + "role": "Author" + }, + { + "name": "All contributors", + "homepage": "https://github.com/FGrosse/PHPASN1/contributors" + } + ], + "description": "A PHP Framework that allows you to encode and decode arbitrary ASN.1 structures using the ITU-T X.690 Encoding Rules.", + "homepage": "https://github.com/FGrosse/PHPASN1", + "time": "2015-07-15 21:26:40" + }, { "name": "graham-campbell/exceptions", "version": "v8.6.1", @@ -1454,6 +1559,54 @@ ], "time": "2015-12-05 17:17:57" }, + { + "name": "kriswallsmith/buzz", + "version": "v0.15", + "source": { + "type": "git", + "url": "https://github.com/kriswallsmith/Buzz.git", + "reference": "d4041666c3ffb379af02a92dabe81c904b35fab8" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/kriswallsmith/Buzz/zipball/d4041666c3ffb379af02a92dabe81c904b35fab8", + "reference": "d4041666c3ffb379af02a92dabe81c904b35fab8", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "3.7.*" + }, + "suggest": { + "ext-curl": "*" + }, + "type": "library", + "autoload": { + "psr-0": { + "Buzz": "lib/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Kris Wallsmith", + "email": "kris.wallsmith@gmail.com", + "homepage": "http://kriswallsmith.net/" + } + ], + "description": "Lightweight HTTP client", + "homepage": "https://github.com/kriswallsmith/Buzz", + "keywords": [ + "curl", + "http client" + ], + "time": "2015-06-25 17:26:56" + }, { "name": "ksubileau/color-thief-php", "version": "v1.3.0", @@ -1780,6 +1933,118 @@ ], "time": "2016-01-22 12:22:23" }, + { + "name": "mdanter/ecc", + "version": "v0.3.0", + "source": { + "type": "git", + "url": "https://github.com/phpecc/phpecc.git", + "reference": "8b588fc094ba743d8f8c84980bcc6b470c4baed7" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/phpecc/phpecc/zipball/8b588fc094ba743d8f8c84980bcc6b470c4baed7", + "reference": "8b588fc094ba743d8f8c84980bcc6b470c4baed7", + "shasum": "" + }, + "require": { + "ext-gmp": "*", + "ext-mcrypt": "*", + "fgrosse/phpasn1": "~1.3.1", + "php": ">=5.4.0", + "symfony/console": "~2.6" + }, + "require-dev": { + "phpunit/phpunit": "~4.1", + "squizlabs/php_codesniffer": "~2", + "symfony/yaml": "~2.6" + }, + "type": "library", + "autoload": { + "psr-4": { + "Mdanter\\Ecc\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Matyas Danter", + "homepage": "http://matejdanter.com/", + "role": "Author" + }, + { + "name": "Thibaud Fabre", + "email": "thibaud@aztech.io", + "homepage": "http://aztech.io", + "role": "Maintainer" + }, + { + "name": "Drak", + "email": "drak@zikula.org", + "homepage": "http://zikula.org", + "role": "Maintainer" + } + ], + "description": "PHP Elliptic Curve Cryptography library", + "homepage": "https://github.com/mdanter/phpecc", + "time": "2014-07-07 12:44:15" + }, + { + "name": "minishlink/web-push", + "version": "v1.0.1", + "source": { + "type": "git", + "url": "https://github.com/Minishlink/web-push.git", + "reference": "ad407ca84e87595760ff87a836a55ad107fa8245" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Minishlink/web-push/zipball/ad407ca84e87595760ff87a836a55ad107fa8245", + "reference": "ad407ca84e87595760ff87a836a55ad107fa8245", + "shasum": "" + }, + "require": { + "kriswallsmith/buzz": ">=0.6", + "lib-openssl": "*", + "mdanter/ecc": "^0.3.0", + "php": ">=5.4", + "spomky-labs/base64url": "^1.0", + "spomky-labs/php-aes-gcm": "^1.0" + }, + "require-dev": { + "phpunit/phpunit": "4.8.*" + }, + "type": "library", + "autoload": { + "psr-4": { + "Minishlink\\WebPush\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Louis Lagrange", + "email": "lagrange.louis@gmail.com", + "homepage": "https://github.com/Minishlink" + } + ], + "description": "Web Push library for PHP", + "homepage": "https://github.com/Minishlink/web-push", + "keywords": [ + "Push API", + "WebPush", + "notifications", + "push", + "web" + ], + "time": "2016-05-13 21:21:54" + }, { "name": "monolog/monolog", "version": "1.19.0", @@ -2400,6 +2665,115 @@ "description": "A lightweight implementation of CommonJS Promises/A for PHP", "time": "2016-05-03 17:50:52" }, + { + "name": "spomky-labs/base64url", + "version": "v1.0.2", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/base64url.git", + "reference": "ef6d5fb93894063d9cee996022259fd08d6646ea" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/base64url/zipball/ef6d5fb93894063d9cee996022259fd08d6646ea", + "reference": "ef6d5fb93894063d9cee996022259fd08d6646ea", + "shasum": "" + }, + "require": { + "php": "^5.3|^7.0" + }, + "require-dev": { + "phpunit/phpunit": "^4.0|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Base64Url\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky-Labs/base64url/contributors" + } + ], + "description": "Base 64 URL Safe Encoding/decoding PHP Library", + "homepage": "https://github.com/Spomky-Labs/base64url", + "keywords": [ + "base64", + "rfc4648", + "safe", + "url" + ], + "time": "2016-01-21 19:50:30" + }, + { + "name": "spomky-labs/php-aes-gcm", + "version": "v1.0.0", + "source": { + "type": "git", + "url": "https://github.com/Spomky-Labs/php-aes-gcm.git", + "reference": "7d415c6eeb5133804a38451d6ed93b5af76872ce" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/Spomky-Labs/php-aes-gcm/zipball/7d415c6eeb5133804a38451d6ed93b5af76872ce", + "reference": "7d415c6eeb5133804a38451d6ed93b5af76872ce", + "shasum": "" + }, + "require": { + "beberlei/assert": "^2.0", + "lib-openssl": "*", + "php": ">=5.4", + "symfony/polyfill-mbstring": "^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^4.5|^5.0", + "satooshi/php-coveralls": "^1.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "AESGCM\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Florent Morselli", + "homepage": "https://github.com/Spomky" + }, + { + "name": "All contributors", + "homepage": "https://github.com/Spomky-Labs/php-aes-gcm/contributors" + } + ], + "description": "AES GCM (Galois Counter Mode) PHP implementation.", + "homepage": "https://github.com/Spomky-Labs/php-aes-gcm", + "keywords": [ + "Galois Counter Mode", + "gcm" + ], + "time": "2016-04-28 13:58:02" + }, { "name": "swiftmailer/swiftmailer", "version": "v5.4.2", diff --git a/config/ponyfm.php b/config/ponyfm.php index 6d7ae949..a0d0ff2b 100644 --- a/config/ponyfm.php +++ b/config/ponyfm.php @@ -115,5 +115,18 @@ return [ | */ - 'user_slug_minimum_length' => 3 + 'user_slug_minimum_length' => 3, + + /* + |-------------------------------------------------------------------------- + | Indexing queue name + |-------------------------------------------------------------------------- + | + | Google Cloud Messaging API key. Needs to be generated in the Google Cloud + | Console as a browser key. This is used to send notifications to users + | with push notifications enabled. + | + */ + + 'gcm_key' => env('GCM_KEY', 'default'), ]; diff --git a/database/migrations/2016_06_10_010314_create_subscription_table.php b/database/migrations/2016_06_10_010314_create_subscription_table.php new file mode 100644 index 00000000..3d4b5bd3 --- /dev/null +++ b/database/migrations/2016_06_10_010314_create_subscription_table.php @@ -0,0 +1,36 @@ +bigIncrements('id'); + $table->unsignedInteger('user_id')->index(); + $table->string('endpoint'); + $table->string('p256dh'); + $table->string('auth'); + $table->timestamps(); + + $table->foreign('user_id')->references('id')->on('users'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::drop('subscriptions'); + } +} diff --git a/public/manifest.json b/public/manifest.json index 133c8434..5c2f814b 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -22,5 +22,6 @@ "background_color": "#EEE", "start_url": "/", "display": "standalone", - "orientation": "portrait" + "orientation": "portrait", + "gcm_sender_id": "628116355343" } diff --git a/public/service-worker.js b/public/service-worker.js index 0e24768e..58eaa9db 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 @@ -48,13 +56,58 @@ self.addEventListener('activate', function (event) { // Basic offline mode // Just respond with an offline error page for now self.addEventListener('fetch', function(event) { - event.respondWith( - caches.match(event.request).then(function(response) { - return response || fetch(event.request); - }).catch(function () { - if (event.request.mode == 'navigate') { - return caches.match('/offline.html'); + if (event.request.url.indexOf('stage.pony.fm') > -1 || event.request.url.indexOf('upload') > -1) { + // Ignore some requests + return; + } else { + event.respondWith( + caches.match(event.request).then(function (response) { + return response || fetch(event.request); + }).catch(function () { + if (event.request.mode == 'navigate') { + return caches.match('/offline.html'); + } + }) + ) + } +}); + +self.addEventListener('push', function(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 c5e6cf37..7e09bd5b 100644 --- a/resources/assets/scripts/app/controllers/application.coffee +++ b/resources/assets/scripts/app/controllers/application.coffee @@ -33,6 +33,7 @@ module.exports = angular.module('ponyfm').controller "application", [ console.log 'SW registered', reg ).catch (err) -> console.log 'SW register failed', err + notifications.serviceWorkerSupported = false $scope.menuToggle = () -> $rootScope.$broadcast('sidebarToggled') diff --git a/resources/assets/scripts/app/directives/notification-list.coffee b/resources/assets/scripts/app/directives/notification-list.coffee index 63abd0d8..d67d64eb 100644 --- a/resources/assets/scripts/app/directives/notification-list.coffee +++ b/resources/assets/scripts/app/directives/notification-list.coffee @@ -21,15 +21,49 @@ module.exports = angular.module('ponyfm').directive 'pfmNotificationList', () -> scope: {} controller: [ - '$scope', 'notifications', '$timeout', '$rootScope' - ($scope, notifications, $timeout, $rootScope) -> + '$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 = () -> + if 'serviceWorker' of navigator && notifications.serviceWorkerSupported + $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 + else + $scope.switchHidden = true refreshNotifications = () -> notifications.getNotifications().done (result) -> @@ -51,5 +85,6 @@ module.exports = angular.module('ponyfm').directive 'pfmNotificationList', () -> isTimeoutScheduled = false , 60000) + checkSubscription() refreshNotifications() ] diff --git a/resources/assets/scripts/app/services/notifications.coffee b/resources/assets/scripts/app/services/notifications.coffee index 7fa2ad8b..9904e157 100644 --- a/resources/assets/scripts/app/services/notifications.coffee +++ b/resources/assets/scripts/app/services/notifications.coffee @@ -19,6 +19,7 @@ module.exports = angular.module('ponyfm').factory('notifications', [ ($rootScope, $http) -> self = notificationList: [] + serviceWorkerSupported: true getNotifications: () -> def = new $.Deferred() @@ -61,5 +62,95 @@ 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 !self.checkPushSupport() + 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() + + checkPushSupport: () -> + if !('showNotification' of ServiceWorkerRegistration.prototype) + console.warn('Notifications aren\'t supported.') + return false + + if Notification.permission == 'denied' + console.warn('The user has blocked notifications.') + return false + + if !('PushManager' of window) + console.warn('Push messaging isn\'t supported.') + return false + + # If Chrome 50+ + if !!window.chrome && !!window.chrome.webstore + if parseInt(navigator.userAgent.match(/Chrom(e|ium)\/([0-9]+)\./)[2]) >= 50 + return true + # If Firefox 46+ + else if typeof InstallTrigger != 'undefined' + if parseInt(navigator.userAgent.match(/Firefox\/([0-9]+)\./)[1]) >= 46 + return true + + return false + 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; +} + diff --git a/resources/environments/.env.local b/resources/environments/.env.local index 042397be..42c8fe39 100644 --- a/resources/environments/.env.local +++ b/resources/environments/.env.local @@ -24,3 +24,4 @@ PONI_CLIENT_SECRET=null PONYFM_DATASTORE=/vagrant/storage/app/datastore GOOGLE_ANALYTICS_ID=null +GCM_KEY=12345 diff --git a/vagrant/install.sh b/vagrant/install.sh index 14b55c27..a3a9b513 100755 --- a/vagrant/install.sh +++ b/vagrant/install.sh @@ -24,6 +24,9 @@ sudo apt-get -qq update echo "Installing tagging tools & other dependencies..." sudo apt-get -qq install -y AtomicParsley flac vorbis-tools imagemagick oracle-java8-installer elasticsearch pkg-config yasm libfaac-dev libmp3lame-dev libvorbis-dev libtheora-dev +echo "Installing PHP extensions" +sudo apt-get -qq install -y libgmp-dev php-gmp + if type ffmpeg &>/dev/null; then echo "ffmpeg is installed!" diff --git a/vagrant/php-overrides.ini b/vagrant/php-overrides.ini index b2c1457d..9a38c514 100644 --- a/vagrant/php-overrides.ini +++ b/vagrant/php-overrides.ini @@ -2,4 +2,4 @@ expose_php = Off post_max_size = 250M -upload_max_filesize = 200M +upload_max_filesize = 200M \ No newline at end of file