diff --git a/resources/celerity/map.php b/resources/celerity/map.php --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -343,6 +343,7 @@ 'rsrc/js/application/calendar/behavior-event-all-day.js' => '38dcf3c8', 'rsrc/js/application/calendar/behavior-recurring-edit.js' => '5f1c4d5f', 'rsrc/js/application/config/behavior-reorder-fields.js' => 'b6993408', + 'rsrc/js/application/config/webpush-subscriptions.js' => 'a3e2b2b7', 'rsrc/js/application/conpherence/ConpherenceThreadManager.js' => '01774ab2', 'rsrc/js/application/conpherence/behavior-drag-and-drop-photo.js' => 'cf86d16a', 'rsrc/js/application/conpherence/behavior-durable-column.js' => 'c72aa091', @@ -438,6 +439,7 @@ 'rsrc/js/core/MultirowRowManager.js' => 'b5d57730', 'rsrc/js/core/Notification.js' => 'ccf1cbf8', 'rsrc/js/core/Prefab.js' => '6920d200', + 'rsrc/js/core/ServiceWorker.js' => 'ac054096', 'rsrc/js/core/ShapedRequest.js' => '7cbe244b', 'rsrc/js/core/TextAreaUtils.js' => '5c93c52c', 'rsrc/js/core/Title.js' => 'df5e11d2', @@ -655,6 +657,7 @@ 'javelin-behavior-typeahead-browse' => '635de1ec', 'javelin-behavior-typeahead-search' => '93d0c9e3', 'javelin-behavior-view-placeholder' => '47830651', + 'javelin-behavior-webpush-subscriptions' => 'a3e2b2b7', 'javelin-behavior-workflow' => '0a3f3021', 'javelin-color' => '7e41274a', 'javelin-cookie' => '62dfea03', @@ -820,6 +823,7 @@ 'releeph-preview-branch' => 'b7a6f4a5', 'releeph-request-differential-create-dialog' => '8d8b92cd', 'releeph-request-typeahead-css' => '667a48ae', + 'service-worker' => 'ac054096', 'setup-issue-css' => 'db7e9c40', 'sprite-login-css' => '1ebb9bf9', 'sprite-main-header-css' => 'f07bbb87', @@ -1603,6 +1607,11 @@ 'javelin-vector', 'javelin-install', ), + 'a3e2b2b7' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), 'a464fe03' => array( 'javelin-behavior', 'javelin-uri', diff --git a/resources/sql/autopatches/20150828.webpush.create.sql b/resources/sql/autopatches/20150828.webpush.create.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20150828.webpush.create.sql @@ -0,0 +1,11 @@ +CREATE TABLE {$NAMESPACE}_user.user_webpushsubscriber ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + userPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, + subscriptionId VARCHAR(255) CHARACTER SET latin1 COLLATE latin1_bin, + endpoint VARCHAR(128) CHARACTER SET latin1 COLLATE latin1_bin, + userAgent VARCHAR(255), + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_subscriber` (subscriptionId, endpoint), + KEY `key_user` (subscriptionId, endpoint) +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -140,6 +140,7 @@ 'AphrontIsolatedDatabaseConnectionTestCase' => 'infrastructure/storage/__tests__/AphrontIsolatedDatabaseConnectionTestCase.php', 'AphrontIsolatedHTTPSink' => 'aphront/sink/AphrontIsolatedHTTPSink.php', 'AphrontJSONResponse' => 'aphront/response/AphrontJSONResponse.php', + 'AphrontJavascriptResponse' => 'aphront/response/AphrontJavascriptResponse.php', 'AphrontJavelinView' => 'view/AphrontJavelinView.php', 'AphrontKeyboardShortcutsAvailableView' => 'view/widget/AphrontKeyboardShortcutsAvailableView.php', 'AphrontListFilterView' => 'view/layout/AphrontListFilterView.php', @@ -2285,6 +2286,7 @@ 'PhabricatorMainMenuSearchView' => 'view/page/menu/PhabricatorMainMenuSearchView.php', 'PhabricatorMainMenuView' => 'view/page/menu/PhabricatorMainMenuView.php', 'PhabricatorManagementWorkflow' => 'infrastructure/management/PhabricatorManagementWorkflow.php', + 'PhabricatorManifestController' => 'applications/webpush/controller/PhabricatorManifestController.php', 'PhabricatorManiphestApplication' => 'applications/maniphest/application/PhabricatorManiphestApplication.php', 'PhabricatorManiphestConfigOptions' => 'applications/maniphest/config/PhabricatorManiphestConfigOptions.php', 'PhabricatorManiphestTaskTestDataGenerator' => 'applications/maniphest/lipsum/PhabricatorManiphestTaskTestDataGenerator.php', @@ -2826,6 +2828,7 @@ 'PhabricatorSecurityConfigOptions' => 'applications/config/option/PhabricatorSecurityConfigOptions.php', 'PhabricatorSecuritySetupCheck' => 'applications/config/check/PhabricatorSecuritySetupCheck.php', 'PhabricatorSendGridConfigOptions' => 'applications/config/option/PhabricatorSendGridConfigOptions.php', + 'PhabricatorServiceworkerController' => 'applications/webpush/controller/PhabricatorServiceworkerController.php', 'PhabricatorSessionsSettingsPanel' => 'applications/settings/panel/PhabricatorSessionsSettingsPanel.php', 'PhabricatorSettingsAddEmailAction' => 'applications/settings/action/PhabricatorSettingsAddEmailAction.php', 'PhabricatorSettingsAdjustController' => 'applications/settings/controller/PhabricatorSettingsAdjustController.php', @@ -3048,12 +3051,17 @@ 'PhabricatorUserTestCase' => 'applications/people/storage/__tests__/PhabricatorUserTestCase.php', 'PhabricatorUserTitleField' => 'applications/people/customfield/PhabricatorUserTitleField.php', 'PhabricatorUserTransaction' => 'applications/people/storage/PhabricatorUserTransaction.php', + 'PhabricatorUserWebPushSubscriber' => 'applications/webpush/storage/PhabricatorUserWebPushSubscriber.php', 'PhabricatorUsersPolicyRule' => 'applications/policy/rule/PhabricatorUsersPolicyRule.php', 'PhabricatorUsersSearchField' => 'applications/people/searchfield/PhabricatorUsersSearchField.php', 'PhabricatorVCSResponse' => 'applications/repository/response/PhabricatorVCSResponse.php', 'PhabricatorVeryWowEnglishTranslation' => 'infrastructure/internationalization/translation/PhabricatorVeryWowEnglishTranslation.php', 'PhabricatorViewerDatasource' => 'applications/people/typeahead/PhabricatorViewerDatasource.php', 'PhabricatorWatcherHasObjectEdgeType' => 'applications/transactions/edges/PhabricatorWatcherHasObjectEdgeType.php', + 'PhabricatorWebpushApplication' => 'applications/webpush/application/PhabricatorWebpushApplication.php', + 'PhabricatorWebpushClient' => 'applications/webpush/client/PhabricatorWebpushClient.php', + 'PhabricatorWebpushConfigOptions' => 'applications/webpush/config/PhabricatorWebpushConfigOptions.php', + 'PhabricatorWebpushNotificationsSettingsPanel' => 'applications/webpush/settings/PhabricatorWebpushNotificationsSettingsPanel.php', 'PhabricatorWordPressAuthProvider' => 'applications/auth/provider/PhabricatorWordPressAuthProvider.php', 'PhabricatorWorker' => 'infrastructure/daemon/workers/PhabricatorWorker.php', 'PhabricatorWorkerActiveTask' => 'infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php', @@ -3759,6 +3767,7 @@ 'AphrontIsolatedDatabaseConnectionTestCase' => 'PhabricatorTestCase', 'AphrontIsolatedHTTPSink' => 'AphrontHTTPSink', 'AphrontJSONResponse' => 'AphrontResponse', + 'AphrontJavascriptResponse' => 'AphrontResponse', 'AphrontJavelinView' => 'AphrontView', 'AphrontKeyboardShortcutsAvailableView' => 'AphrontView', 'AphrontListFilterView' => 'AphrontView', @@ -6229,6 +6238,7 @@ 'PhabricatorMainMenuSearchView' => 'AphrontView', 'PhabricatorMainMenuView' => 'AphrontView', 'PhabricatorManagementWorkflow' => 'PhutilArgumentWorkflow', + 'PhabricatorManifestController' => 'PhabricatorController', 'PhabricatorManiphestApplication' => 'PhabricatorApplication', 'PhabricatorManiphestConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorManiphestTaskTestDataGenerator' => 'PhabricatorTestDataGenerator', @@ -6887,6 +6897,7 @@ 'PhabricatorSecurityConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorSecuritySetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorSendGridConfigOptions' => 'PhabricatorApplicationConfigOptions', + 'PhabricatorServiceworkerController' => 'PhabricatorController', 'PhabricatorSessionsSettingsPanel' => 'PhabricatorSettingsPanel', 'PhabricatorSettingsAddEmailAction' => 'PhabricatorSystemAction', 'PhabricatorSettingsAdjustController' => 'PhabricatorController', @@ -7142,12 +7153,17 @@ 'PhabricatorUserTestCase' => 'PhabricatorTestCase', 'PhabricatorUserTitleField' => 'PhabricatorUserCustomField', 'PhabricatorUserTransaction' => 'PhabricatorApplicationTransaction', + 'PhabricatorUserWebPushSubscriber' => 'PhabricatorUserDAO', 'PhabricatorUsersPolicyRule' => 'PhabricatorPolicyRule', 'PhabricatorUsersSearchField' => 'PhabricatorSearchTokenizerField', 'PhabricatorVCSResponse' => 'AphrontResponse', 'PhabricatorVeryWowEnglishTranslation' => 'PhutilTranslation', 'PhabricatorViewerDatasource' => 'PhabricatorTypeaheadDatasource', 'PhabricatorWatcherHasObjectEdgeType' => 'PhabricatorEdgeType', + 'PhabricatorWebpushApplication' => 'PhabricatorApplication', + 'PhabricatorWebpushClient' => 'Phobject', + 'PhabricatorWebpushConfigOptions' => 'PhabricatorApplicationConfigOptions', + 'PhabricatorWebpushNotificationsSettingsPanel' => 'PhabricatorSettingsPanel', 'PhabricatorWordPressAuthProvider' => 'PhabricatorOAuth2AuthProvider', 'PhabricatorWorker' => 'Phobject', 'PhabricatorWorkerActiveTask' => 'PhabricatorWorkerTask', diff --git a/src/aphront/response/AphrontJavascriptResponse.php b/src/aphront/response/AphrontJavascriptResponse.php new file mode 100644 --- /dev/null +++ b/src/aphront/response/AphrontJavascriptResponse.php @@ -0,0 +1,24 @@ +content = $content; + return $this; + } + + public function buildResponseString() { + return $this->content; + } + + public function getHeaders() { + $headers = array( + array('Content-Type', 'text/javascript; charset=utf-8'), + ); + + return array_merge(parent::getHeaders(), $headers); + } + +} diff --git a/src/applications/conpherence/editor/ConpherenceEditor.php b/src/applications/conpherence/editor/ConpherenceEditor.php --- a/src/applications/conpherence/editor/ConpherenceEditor.php +++ b/src/applications/conpherence/editor/ConpherenceEditor.php @@ -431,6 +431,15 @@ ); PhabricatorNotificationClient::tryToPostMessage($data); + + $data = array( + 'type' => 'message', + 'threadPHID' => $object->getPHID(), + 'messageID' => last($xactions)->getID(), + 'subscribers' => $participants, + 'userPHID' => $user->getPHID(), + ); + PhabricatorWebpushClient::tryToPostMessage($data); } return $xactions; diff --git a/src/applications/people/editor/PhabricatorUserEditor.php b/src/applications/people/editor/PhabricatorUserEditor.php --- a/src/applications/people/editor/PhabricatorUserEditor.php +++ b/src/applications/people/editor/PhabricatorUserEditor.php @@ -669,6 +669,93 @@ $user->saveTransaction(); } + /* -( Subscribing and Unsubscribing from Push Notifications )----------- */ + + /** + * @task webpush + */ + public function addSubscription( + PhabricatorUser $user, + PhabricatorUserWebPushSubscriber $subscriber) { + + $actor = $this->requireActor(); + + if (!$user->getID()) { + throw new Exception(pht('User has not been created yet!')); + } + if ($subscriber->getID()) { + throw new Exception(pht('Subscription has already been created!')); + } + + $user->openTransaction(); + $user->beginWriteLocking(); + + $user->reload(); + + try { + $subscriber->save(); + } catch (AphrontDuplicateKeyQueryException $ex) { + $user->endWriteLocking(); + $user->killTransaction(); + + throw $ex; + } + + $log = PhabricatorUserLog::initializeNewLog( + $actor, + $user->getPHID(), + PhabricatorUserLog::ACTION_WEBPUSH_ADD); + $log->setNewValue($subscriber->getSubscriptionId()); + $log->save(); + + $user->endWriteLocking(); + $user->saveTransaction(); + + return $this; + } + + /** + * @task webpush + */ + public function removeSubscription( + PhabricatorUser $user, + PhabricatorUserWebPushSubscriber $subscriber) { + + $actor = $this->requireActor(); + + if (!$user->getID()) { + throw new Exception(pht('User has not been created yet!')); + } + if (!$subscriber->getID()) { + throw new Exception(pht('Subscription has already been deleted!')); + } + + $user->openTransaction(); + $user->beginWriteLocking(); + + $user->reload(); + + try { + $subscriber->delete(); + } catch (AphrontQueryException $ex) { + $user->endWriteLocking(); + $user->killTransaction(); + + throw $ex; + } + + $log = PhabricatorUserLog::initializeNewLog( + $actor, + $user->getPHID(), + PhabricatorUserLog::ACTION_WEBPUSH_REMOVE); + $log->setNewValue($subscriber->getSubscriptionId()); + $log->save(); + + $user->endWriteLocking(); + $user->saveTransaction(); + + return $this; + } /* -( Internals )---------------------------------------------------------- */ diff --git a/src/applications/people/storage/PhabricatorUserLog.php b/src/applications/people/storage/PhabricatorUserLog.php --- a/src/applications/people/storage/PhabricatorUserLog.php +++ b/src/applications/people/storage/PhabricatorUserLog.php @@ -40,6 +40,9 @@ const ACTION_MULTI_ADD = 'multi-add'; const ACTION_MULTI_REMOVE = 'multi-remove'; + const ACTION_WEBPUSH_ADD = 'webpush-add'; + const ACTION_WEBPUSH_REMOVE = 'webpush-remove'; + protected $actorPHID; protected $userPHID; protected $action; @@ -83,6 +86,8 @@ self::ACTION_FAIL_HISEC => pht('Hisec: Failed Attempt'), self::ACTION_MULTI_ADD => pht('Multi-Factor: Add Factor'), self::ACTION_MULTI_REMOVE => pht('Multi-Factor: Remove Factor'), + self::ACTION_WEBPUSH_ADD => pht('Web Push: Add browser'), + self::ACTION_WEBPUSH_REMOVE => pht('Web Push: Remove browser'), ); } diff --git a/src/applications/webpush/application/PhabricatorWebpushApplication.php b/src/applications/webpush/application/PhabricatorWebpushApplication.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/application/PhabricatorWebpushApplication.php @@ -0,0 +1,28 @@ + 'PhabricatorServiceworkerController', + '/manifest.json' => 'PhabricatorManifestController', + ); + } + + public function isLaunchable() { + return false; + } + +} diff --git a/src/applications/webpush/client/PhabricatorWebpushClient.php b/src/applications/webpush/client/PhabricatorWebpushClient.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/client/PhabricatorWebpushClient.php @@ -0,0 +1,83 @@ +getParticipantPHID() != $data['userPHID']) { + $subscribers[] = $participant->getParticipantPHID(); + } + } + if (empty($subscribers)) { + return; + } + self::sendNotifications($subscribers); + } + + private static function sendNotifications(array $subscribers) { + $gcm_api_key = PhabricatorEnv::getEnvConfig('webpush.gcm-api-key'); + + foreach ($subscribers as $user_phid) { + $subscriptions = id(new PhabricatorUserWebPushSubscriber())->loadAllWhere( + 'userPHID = %s', + $user_phid); + + if (empty($subscriptions)) { + continue; + } + foreach ($subscriptions as $subscription) { + if (strpos($subscription->getEndpoint(), 'android.googleapis.com')) { + $message = array( + 'to' => $subscription->getSubscriptionId(), + 'notification' => array( + 'title' => 'Notification', + 'body' => 'New Phabricator Activity', + ), + ); + $server_uri = id(new PhutilURI($subscription->getEndpoint())); + try { + id(new HTTPSFuture($server_uri, json_encode($message))) + ->setMethod('POST') + ->addHeader('Content-Type', 'application/json') + ->addHeader('Authorization', 'key='.$gcm_api_key) + ->setTimeout(1) + ->resolvex(); + } catch (Exception $e) { + phlog($e); + } + } else { + $server_uri = id(new PhutilURI($subscription->getEndpoint() + .'/'.$subscription->getSubscriptionId())); + try { + id(new HTTPSFuture($server_uri, + 'version='.str_replace('.', '', microtime(true)))) + ->setMethod('PUT') + ->setTimeout(2) + ->resolvex(); + } catch (Exception $e) { + phlog($e); + } + } + } + } + } +} diff --git a/src/applications/webpush/config/PhabricatorWebpushConfigOptions.php b/src/applications/webpush/config/PhabricatorWebpushConfigOptions.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/config/PhabricatorWebpushConfigOptions.php @@ -0,0 +1,46 @@ +newOption('webpush.enabled', 'bool', false) + ->setBoolOptions( + array( + pht('Enable Web Push Notifications'), + pht('Disable Web Push Notifications'), + )) + ->setSummary(pht('Enable Web Push notifications.')) + ->setDescription( + pht( + 'Enable Web Push desktop or mobile browser notifications. '. + 'For Chrome browser notifications you need to set up a project in '. + 'https://console.developers.google.com/project and '. + 'enable Cloud Messaging for Android API to obtain the API key.')), + $this->newOption('webpush.gcm-sender-id', 'string', null) + ->setLocked(true) + ->setDescription(pht('Project ID for Google Cloud Messaging.')), + $this->newOption('webpush.gcm-api-key', 'string', null) + ->setHidden(true) + ->setDescription(pht('Secret key for Google Cloud Messaging.')), + ); + } + +} diff --git a/src/applications/webpush/controller/PhabricatorManifestController.php b/src/applications/webpush/controller/PhabricatorManifestController.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/controller/PhabricatorManifestController.php @@ -0,0 +1,36 @@ + '/favicon.ico', + 'sizes' => '64x64', + 'type' => 'image/x-icon', + ], + ); + $out['start_url'] = '/'; + $out['display'] = 'standalone'; + $out['gcm_user_visible_only'] = true; + $out['gcm_sender_id'] = $gcm_sender_id; + + return id(new AphrontJSONResponse()) + ->setCacheDurationInSeconds(phutil_units('2 hours in seconds')) + ->setAddJSONShield(false) + ->setContent($out); + } +} diff --git a/src/applications/webpush/controller/PhabricatorServiceworkerController.php b/src/applications/webpush/controller/PhabricatorServiceworkerController.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/controller/PhabricatorServiceworkerController.php @@ -0,0 +1,19 @@ +getResourceNameForSymbol('service-worker'); + $content = $map->getResourceDataForName($name); + + return id(new AphrontJavascriptResponse()) + ->setCacheDurationInSeconds(phutil_units('2 hours in seconds')) + ->setContent($content); + } +} diff --git a/src/applications/webpush/settings/PhabricatorWebpushNotificationsSettingsPanel.php b/src/applications/webpush/settings/PhabricatorWebpushNotificationsSettingsPanel.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/settings/PhabricatorWebpushNotificationsSettingsPanel.php @@ -0,0 +1,230 @@ +getUser()->getIsMailingList()) { + return true; + } + + return false; + } + + public function processRequest(AphrontRequest $request) { + $user = $this->getUser(); + $editable = PhabricatorEnv::getEnvConfig('account.editable'); + + $uri = $request->getRequestURI(); + $uri->setQueryParams(array()); + + if ($editable) { + $new = $request->getStr('new'); + if ($new) { + return $this->returnNewSubscriptionResponse($request, $uri, $new); + } + + $delete = $request->getInt('delete'); + if ($delete) { + return $this->returnDeleteSubscriptionResponse($request, $uri, $delete); + } + } + + $subscriptions = id(new PhabricatorUserWebPushSubscriber())->loadAllWhere( + 'userPHID = %s ORDER BY dateCreated DESC', + $user->getPHID()); + + $rows = array(); + foreach ($subscriptions as $subscription) { + + $button_remove = javelin_tag( + 'a', + array( + 'class' => 'button small grey', + 'href' => $uri->alter('delete', $subscription->getID()), + 'sigil' => 'workflow', + ), + pht('Remove')); + + $remove = $button_remove; + + $rows[] = array( + $subscription->getUserAgent(), + $remove, + ); + } + + $table = new AphrontTableView($rows); + $table->setHeaders( + array( + pht('Browser'), + pht('Action'), + )); + $table->setColumnClasses( + array( + 'wide', + 'action', + )); + $table->setColumnVisibility( + array( + true, + $editable, + )); + + $view = new PHUIObjectBoxView(); + $header = new PHUIHeaderView(); + $header->setHeader(pht('Push Notifications')); + + if ($editable) { + $icon = id(new PHUIIconView()) + ->setIconFont('fa-plus'); + + $button = new PHUIButtonView(); + $button->setText(pht('Add Current Browser')); + $button->setTag('a'); + $button->setHref($uri->alter('new', 'true')); + $button->setIcon($icon); + $button->addSigil('workflow'); + $header->addActionLink($button); + } + $view->appendChild(phutil_tag( + 'link', + array( + 'rel' => 'manifest', + 'href' => '/manifest.json', + ))); + $view->setHeader($header); + $view->setTable($table); + + return $view; + } + + private function returnNewSubscriptionResponse( + AphrontRequest $request, + PhutilURI $uri, + $new) { + + $user = $this->getUser(); + $viewer = $this->getViewer(); + + $subscription_id = null; + $endpoint = null; + $errors = array(); + if ($request->isDialogFormPost()) { + $endpoint = trim($request->getStr('endpoint')); + + if (!strlen($endpoint)) { + $errors[] = pht('Browser data is missing.'); + } + + $subscription_id = basename($endpoint); + $endpoint = dirname($endpoint); + + if (!$errors) { + $object = id(new PhabricatorUserWebPushSubscriber()) + ->setEndpoint($endpoint) + ->setSubscriptionId($subscription_id) + ->setUserAgent(idx($_SERVER, 'HTTP_USER_AGENT')) + ->setUserPHID($user->getPHID()); + + try { + id(new PhabricatorUserEditor()) + ->setActor($viewer) + ->addSubscription($user, $object); + + return id(new AphrontReloadResponse())->setURI($uri); + + } catch (AphrontDuplicateKeyQueryException $ex) { + $errors[] = pht('This browser is already subscribed ' + .'to push notifications.'); + } + } + } + + if ($errors) { + $errors = id(new PHUIInfoView()) + ->setErrors($errors); + } + + $dialog = id(new AphrontDialogView()) + ->setFormID('webpush-subscribe') + ->setUser($viewer) + ->addHiddenInput('new', 'true') + ->addHiddenInput('endpoint', '') + ->setTitle(pht('Subscribe this browser')) + ->appendChild($errors) + ->appendChild(phutil_tag('p', array(), pht( + 'Your browser needs to support Service Workers and Push '. + 'Notifications. Most recent versions of Chrome and Firefox do.'))) + ->addCancelButton($uri); + + if (!$errors) { + $dialog->addSubmitButton(pht('Subscribe')); + } + + require_celerity_resource('javelin-behavior-webpush-subscriptions'); + Javelin::initBehavior('webpush-subscriptions'); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } + + private function returnDeleteSubscriptionResponse( + AphrontRequest $request, + PhutilURI $uri, + $subscription_id) { + $user = $this->getUser(); + $viewer = $this->getViewer(); + + $subscriber = id(new PhabricatorUserWebPushSubscriber())->loadOneWhere( + 'id = %d AND userPHID = %s', + $subscription_id, + $user->getPHID()); + + if (!$subscriber) { + return new Aphront404Response(); + } + + if ($request->isFormPost()) { + id(new PhabricatorUserEditor()) + ->setActor($viewer) + ->removeSubscription($user, $subscriber); + + return id(new AphrontRedirectResponse())->setURI($uri); + } + + $dialog = id(new AphrontDialogView()) + ->setUser($viewer) + ->addHiddenInput('delete', $subscription_id) + ->setTitle(pht('Really delete subscription?')) + ->appendParagraph( + pht( + 'Are you sure you want to unsubscribe this browser?')) + ->addSubmitButton(pht('Unsubscribe')) + ->addCancelButton($uri); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } + +} diff --git a/src/applications/webpush/storage/PhabricatorUserWebPushSubscriber.php b/src/applications/webpush/storage/PhabricatorUserWebPushSubscriber.php new file mode 100644 --- /dev/null +++ b/src/applications/webpush/storage/PhabricatorUserWebPushSubscriber.php @@ -0,0 +1,33 @@ + array( + 'subscriptionId' => 'text255', + 'endpoint' => 'text128', + 'userAgent' => 'text255', + ), + self::CONFIG_KEY_SCHEMA => array( + 'subscriber' => array( + 'columns' => array('subscriptionId', 'endpoint'), + 'unique' => true, + ), + 'userPHID' => array( + 'columns' => array('userPHID'), + ), + ), + ) + parent::getConfiguration(); + } + +} diff --git a/src/view/page/PhabricatorBarePageView.php b/src/view/page/PhabricatorBarePageView.php --- a/src/view/page/PhabricatorBarePageView.php +++ b/src/view/page/PhabricatorBarePageView.php @@ -75,6 +75,14 @@ 'maximum-scale=1', )); } + + $manifest_tag = phutil_tag( + 'link', + array( + 'rel' => 'manifest', + 'href' => '/manifest.json', + )); + $icon_tag_76 = phutil_tag( 'link', array( @@ -130,8 +138,9 @@ $developer = PhabricatorEnv::getEnvConfig('phabricator.developer-mode'); return hsprintf( - '%s%s%s%s%s%s%s%s', + '%s%s%s%s%s%s%s%s%s', $viewport_tag, + $manifest_tag, $icon_tag_76, $icon_tag_120, $icon_tag_152, diff --git a/webroot/rsrc/js/application/config/webpush-subscriptions.js b/webroot/rsrc/js/application/config/webpush-subscriptions.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/application/config/webpush-subscriptions.js @@ -0,0 +1,107 @@ +/** + * @provides javelin-behavior-webpush-subscriptions + * @requires javelin-behavior javelin-stratcom javelin-dom + */ +JX.behavior('webpush-subscriptions', function() { + + function show_message(msg) { + alert(msg); + } + + var form = JX.$('webpush-subscribe'); + var submit_button, endpoint_input; + var inputs = JX.DOM.scry(form, 'input'); + var i; + for (i = 0; i < inputs.length; i++) { + if (inputs[i].type != 'hidden') { + continue; + } + if (inputs[i].name == 'endpoint') { + endpoint_input = inputs[i]; + break; + } + } + var buttons = JX.DOM.scry(form, 'button'); + for (i = 0; i < buttons.length; i++) { + if (buttons[i].name == '__submit__') { + submit_button = buttons[i]; + break; + } + } + + JX.DOM.listen(submit_button, 'click', null, function (event) { + if (endpoint_input.value.length > 0) { + return true; + } + + event.kill(); + + if (!('serviceWorker' in navigator) || + !('showNotification' in ServiceWorkerRegistration.prototype) || + !('PushManager' in window)) { + show_message('Notifications are not supported by your browser.'); + return false; + } + navigator.serviceWorker.register('/serviceworker.js') + .then(function(svWorker) { + switch (Notification.permission) { + case 'denied': + show_message('Please enable notifications for this site ' + + 'in your browser\'s settings.'); + break; + case 'granted': + svWorker.pushManager.getSubscription().then(function(subscription) { + if (subscription) { + handle_subscription(subscription); + } else { + show_message('Error fetching subscription details from browser'); + } + }) + .catch(function(err) { + console.log(err); + svWorker.pushManager.subscribe({userVisibleOnly: true}) + .then(function(subscription) { + handle_subscription(subscription); + }) + .catch(function(err) { + console.log(err); + show_message('Please enable notifications for this site'); + window.location.reload(); + }); + }); + break; + default: + svWorker.pushManager.subscribe({userVisibleOnly: true}) + .then(function(subscription) { + handle_subscription(subscription); + }) + .catch(function(err) { + console.log(err); + show_message('Please enable notifications for this site'); + window.location.reload(); + }); + break; + } + }); + return false; + }); + + function handle_subscription(subscription) { + var endpoint = ''; + if ('endpoint' in subscription) { + endpoint = subscription.endpoint; + } + if (endpoint.length < 1) { + show_message('Error fetching subscription details from browser'); + return; + } + endpoint_input.value = endpoint; + var event = new MouseEvent('click', { + 'view': window, + 'bubbles': true, + 'cancelable': true + }); + submit_button.dispatchEvent(event); + } + +}); diff --git a/webroot/rsrc/js/core/ServiceWorker.js b/webroot/rsrc/js/core/ServiceWorker.js new file mode 100644 --- /dev/null +++ b/webroot/rsrc/js/core/ServiceWorker.js @@ -0,0 +1,33 @@ +/** + * @provides service-worker + */ + +self.addEventListener('push', function(event) { + event.waitUntil(new Promise(function(resolve, reject) { + self.registration.showNotification('New activity on Phabricator', { + body: 'The System has detected new activity on your Phabricator. '+ + 'Click to find out more.', + icon: '/favicon.ico', + tag: 'new_activity' + }).then(function() { + resolve('OK'); + }).catch(function (e) { + console.error(e); + }); + })); +}); + +self.addEventListener('notificationclick', function(event) { + event.notification.close(); + if (clients.openWindow) { + return clients.openWindow('/'); + } +}); + +self.addEventListener('install', function(event) { + if ('replace' in event) { + event.replace(); + } else { + event.waitUntil(self.skipWaiting()); + } +});