Page MenuHomePhabricator

D14011.diff
No OneTemporary

D14011.diff

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 @@
+<?php
+
+final class AphrontJavascriptResponse extends AphrontResponse {
+
+ private $content;
+
+ public function setContent($content) {
+ $this->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 @@
+<?php
+
+final class PhabricatorWebpushApplication extends PhabricatorApplication {
+
+ public function getName() {
+ return pht('Web Push Notifications');
+ }
+
+ public function getShortDescription() {
+ return pht('Real-Time Web Push Updates and Alerts');
+ }
+
+ public function getFontIcon() {
+ return 'fa-bell';
+ }
+
+ public function getRoutes() {
+ return array(
+ '/serviceworker.js' => '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 @@
+<?php
+
+final class PhabricatorWebpushClient extends Phobject {
+
+ public static function tryToPostMessage(array $data) {
+ if (!PhabricatorEnv::getEnvConfig('webpush.enabled')) {
+ return;
+ }
+ try {
+ self::postMessage($data);
+ } catch (Exception $ex) {
+ // Just ignore any issues here.
+ phlog($ex);
+ }
+ }
+
+ private static function postMessage(array $data) {
+ if (empty($data['threadPHID'])) {
+ return;
+ }
+ if (empty($data['subscribers'])) {
+ return;
+ }
+ $subscribers = [];
+ foreach ($data['subscribers'] as $participant) {
+ if ($participant->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 @@
+<?php
+
+final class PhabricatorWebpushConfigOptions
+ extends PhabricatorApplicationConfigOptions {
+
+ public function getName() {
+ return pht('Web Push Notifications');
+ }
+
+ public function getDescription() {
+ return pht('Configure Web Push notifications settings.');
+ }
+
+ public function getFontIcon() {
+ return 'fa-bell';
+ }
+
+ public function getGroup() {
+ return 'core';
+ }
+
+ public function getOptions() {
+ return array(
+ $this->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 @@
+<?php
+
+final class PhabricatorManifestController extends PhabricatorController {
+
+ public function shouldRequireLogin() {
+ return false;
+ }
+
+ public function processRequest() {
+ $out = array();
+
+ $gcm_sender_id = PhabricatorEnv::getEnvConfig('webpush.gcm-sender-id');
+ if (empty($gcm_sender_id)) {
+ return new Aphront404Response();
+ }
+
+ $out['name'] = 'Phabricator';
+ $out['short_name'] = 'Phabricator';
+ $out['icons'] = array(
+ [
+ 'src' => '/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 @@
+<?php
+
+final class PhabricatorServiceworkerController extends PhabricatorController {
+
+ public function shouldRequireLogin() {
+ return false;
+ }
+
+ public function processRequest() {
+
+ $map = CelerityResourceMap::getNamedInstance('phabricator');
+ $name = $map->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 @@
+<?php
+
+final class PhabricatorWebpushNotificationsSettingsPanel
+ extends PhabricatorSettingsPanel {
+
+ public function getPanelKey() {
+ return 'webpush';
+ }
+
+ public function getPanelName() {
+ return pht('Subscriptions');
+ }
+
+ public function getPanelGroup() {
+ return pht('Push Notifications');
+ }
+
+ public function isEnabled() {
+ $enabled = PhabricatorEnv::getEnvConfig('webpush.enabled')
+ && PhabricatorEnv::getEnvConfig('webpush.gcm-sender-id')
+ && PhabricatorEnv::getEnvConfig('webpush.gcm-api-key')
+ && strncmp(PhabricatorEnv::getEnvConfig('phabricator.base-uri'),
+ 'https://', 8) === 0;
+
+ return $enabled;
+ }
+
+ public function isEditableByAdministrators() {
+ if ($this->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 @@
+<?php
+
+/**
+ * @task restrictions Domain Restrictions
+ * @task email Email About Email
+ */
+final class PhabricatorUserWebPushSubscriber extends PhabricatorUserDAO {
+
+ protected $userPHID;
+ protected $subscriptionId;
+ protected $endpoint;
+ protected $userAgent;
+
+ protected function getConfiguration() {
+ return array(
+ self::CONFIG_COLUMN_SCHEMA => 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());
+ }
+});

File Metadata

Mime Type
text/plain
Expires
Tue, Jan 14, 1:09 AM (6 h, 45 m)
Storage Engine
blob
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
6990948
Default Alt Text
D14011.diff (34 KB)

Event Timeline