Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F14680355
D14011.diff
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
34 KB
Referenced Files
None
Subscribers
None
D14011.diff
View Options
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
Details
Attached
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)
Attached To
Mode
D14011: Web Push notifications
Attached
Detach File
Event Timeline
Log In to Comment