diff --git a/conf/default.conf.php b/conf/default.conf.php index fe70ae2d32..4c8b67e5cf 100644 --- a/conf/default.conf.php +++ b/conf/default.conf.php @@ -179,6 +179,8 @@ return array( // extend AphrontMySQLDatabaseConnectionBase. 'mysql.implementation' => 'AphrontMySQLDatabaseConnection', +// -- Notifications ----// + 'notification.enabled' => false, // -- Email ----------------------------------------------------------------- // diff --git a/resources/sql/patches/temp.notifications.sql b/resources/sql/patches/temp.notifications.sql new file mode 100644 index 0000000000..f10664d539 --- /dev/null +++ b/resources/sql/patches/temp.notifications.sql @@ -0,0 +1,8 @@ +CREATE TABLE if not exists phabricator_feed.feed_storynotification ( + userPHID varchar(64) not null collate utf8_bin, + primaryObjectPHID varchar(64) not null collate utf8_bin, + chronologicalKey BIGINT UNSIGNED NOT NULL, + hasViewed boolean not null, + UNIQUE KEY (userPHID, chronologicalKey), + KEY (userPHID, hasViewed, primaryObjectPHID) +); diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index bb522ae3e1..7ef53f83cd 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -525,6 +525,9 @@ phutil_register_library_map(array( 'ManiphestView' => 'applications/maniphest/view/ManiphestView.php', 'MetaMTAConstants' => 'applications/metamta/constants/MetaMTAConstants.php', 'MetaMTANotificationType' => 'applications/metamta/constants/MetaMTANotificationType.php', + 'NotificationMessage' => 'applications/notification/constants/message/NotificationMessage.php', + 'NotificationPathname' => 'applications/notification/constants/pathname/NotificationPathname.php', + 'NotificationType' => 'applications/notification/constants/type/NotificationType.php', 'OwnersPackageReplyHandler' => 'applications/owners/OwnersPackageReplyHandler.php', 'PackageCreateMail' => 'applications/owners/mail/PackageCreateMail.php', 'PackageDeleteMail' => 'applications/owners/mail/PackageDeleteMail.php', @@ -631,6 +634,7 @@ phutil_register_library_map(array( 'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php', 'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php', 'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php', + 'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php', 'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php', 'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php', 'PhabricatorFeedStoryPublisher' => 'applications/feed/PhabricatorFeedStoryPublisher.php', @@ -733,6 +737,16 @@ phutil_register_library_map(array( 'PhabricatorMetaMTAWorker' => 'applications/metamta/PhabricatorMetaMTAWorker.php', 'PhabricatorMustVerifyEmailController' => 'applications/auth/controller/PhabricatorMustVerifyEmailController.php', 'PhabricatorMySQLFileStorageEngine' => 'applications/files/engine/PhabricatorMySQLFileStorageEngine.php', + 'PhabricatorNotificationBuilder' => 'applications/notification/builder/notification/PhabricatorNotificationBuilder.php', + 'PhabricatorNotificationConstants' => 'applications/notification/constants/base/PhabricatorNotificationConstants.php', + 'PhabricatorNotificationController' => 'applications/notification/controller/base/PhabricatorNotificationController.php', + 'PhabricatorNotificationPanelController' => 'applications/notification/controller/panel/PhabricatorNotificationPanelController.php', + 'PhabricatorNotificationQuery' => 'applications/notification/PhabricatorNotificationQuery.php', + 'PhabricatorNotificationStory' => 'applications/notification/base/PhabricatorNotificationStory.php', + 'PhabricatorNotificationStoryTypeConstants' => 'applications/notification/constants/story/PhabricatorNotificationStoryTypeConstants.php', + 'PhabricatorNotificationStoryView' => 'applications/notification/view/story/PhabricatorNotificationStoryView.php', + 'PhabricatorNotificationTestController' => 'applications/notification/controller/test/PhabricatorNotificationTestController.php', + 'PhabricatorNotificationView' => 'applications/notification/view/base/PhabricatorNotificationView.php', 'PhabricatorNotificationsController' => 'applications/notifications/controller/PhabricatorNotificationsController.php', 'PhabricatorOAuthClientAuthorization' => 'applications/oauthserver/storage/PhabricatorOAuthClientAuthorization.php', 'PhabricatorOAuthClientAuthorizationBaseController' => 'applications/oauthserver/controller/clientauthorization/PhabricatorOAuthClientAuthorizationBaseController.php', @@ -1511,6 +1525,9 @@ phutil_register_library_map(array( 'ManiphestTransactionType' => 'ManiphestConstants', 'ManiphestView' => 'AphrontView', 'MetaMTANotificationType' => 'MetaMTAConstants', + 'NotificationMessage' => 'PhabricatorNotificationConstants', + 'NotificationPathname' => 'PhabricatorNotificationConstants', + 'NotificationType' => 'PhabricatorNotificationConstants', 'OwnersPackageReplyHandler' => 'PhabricatorMailReplyHandler', 'PackageCreateMail' => 'PackageMail', 'PackageDeleteMail' => 'PackageMail', @@ -1604,6 +1621,7 @@ phutil_register_library_map(array( 'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO', 'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory', + 'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO', 'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory', 'PhabricatorFeedStoryReference' => 'PhabricatorFeedDAO', @@ -1688,6 +1706,12 @@ phutil_register_library_map(array( 'PhabricatorMetaMTAWorker' => 'PhabricatorWorker', 'PhabricatorMustVerifyEmailController' => 'PhabricatorAuthController', 'PhabricatorMySQLFileStorageEngine' => 'PhabricatorFileStorageEngine', + 'PhabricatorNotificationController' => 'PhabricatorController', + 'PhabricatorNotificationPanelController' => 'PhabricatorNotificationController', + 'PhabricatorNotificationStoryTypeConstants' => 'PhabricatorNotificationConstants', + 'PhabricatorNotificationStoryView' => 'PhabricatorNotificationView', + 'PhabricatorNotificationTestController' => 'PhabricatorNotificationController', + 'PhabricatorNotificationView' => 'AphrontView', 'PhabricatorNotificationsController' => 'PhabricatorController', 'PhabricatorOAuthClientAuthorization' => 'PhabricatorOAuthServerDAO', 'PhabricatorOAuthClientAuthorizationBaseController' => 'PhabricatorOAuthServerController', diff --git a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php index 34556cbd5a..2608e0b2df 100644 --- a/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontDefaultApplicationConfiguration.php @@ -421,8 +421,7 @@ class AphrontDefaultApplicationConfiguration 'PhabricatorChatLogChannelLogController', ), - '/aphlict/' => 'PhabricatorAphlictTestPageController', - + '/notification/test/' => 'PhabricatorNotificationTestController', '/flag/' => array( '' => 'PhabricatorFlagListController', 'view/(?P[^/]+)/' => 'PhabricatorFlagListController', diff --git a/src/applications/feed/PhabricatorFeedStoryPublisher.php b/src/applications/feed/PhabricatorFeedStoryPublisher.php index ae64f0f20f..2d54e4a567 100644 --- a/src/applications/feed/PhabricatorFeedStoryPublisher.php +++ b/src/applications/feed/PhabricatorFeedStoryPublisher.php @@ -1,7 +1,7 @@ relatedPHIDs = $phids; return $this; } + public function setSubscribedPHIDs(array $phids) { + $this->subscribedPHIDs = $phids; + return $this; + } + public function setPrimaryObjectPHID($phid) { + $this->primaryObjectPHID = $phid; + return $this; + } + public function setStoryType($story_type) { $this->storyType = $story_type; return $this; @@ -85,9 +96,43 @@ final class PhabricatorFeedStoryPublisher { $ref->getTableName(), implode(', ', $sql)); + if (PhabricatorEnv::getEnvConfig('notification.enabled')) { + $this->insertNotifications($chrono_key); + } return $story; } + + private function insertNotifications($chrono_key) { + if (!$this->primaryObjectPHID) { + throw + new Exception("Call setPrimaryObjectPHID() before Publishing!"); + } + if ($this->subscribedPHIDs) { + $notif = new PhabricatorFeedStoryNotification(); + $sql = array(); + $conn = $notif->establishConnection('w'); + + foreach (array_unique($this->subscribedPHIDs) as $user_phid) { + $sql[] = qsprintf( + $conn, + '(%s, %s, %s, %d)', + $this->primaryObjectPHID, + $user_phid, + $chrono_key, + 0); + } + + queryfx( + $conn, + 'INSERT INTO %T + (primaryObjectPHID, userPHID, chronologicalKey, hasViewed) + VALUES %Q', + $notif->getTableName(), + implode(', ', $sql)); + } + + } /** * We generate a unique chronological key for each story type because we want * to be able to page through the stream with a cursor (i.e., select stories diff --git a/src/applications/feed/story/PhabricatorFeedStory.php b/src/applications/feed/story/PhabricatorFeedStory.php index fb466b9439..c1d696dc53 100644 --- a/src/applications/feed/story/PhabricatorFeedStory.php +++ b/src/applications/feed/story/PhabricatorFeedStory.php @@ -19,7 +19,7 @@ abstract class PhabricatorFeedStory { private $data; - + private $hasViewed; private $handles; private $framed; @@ -33,6 +33,15 @@ abstract class PhabricatorFeedStory { return array(); } + public function setHasViewed($has_viewed) { + $this->hasViewed = $has_viewed; + return $this; + } + + public function getHasViewed() { + return $this->hasViewed; + } + public function getRequiredObjectPHIDs() { return array(); } diff --git a/src/applications/feed/story/PhabricatorFeedStoryManiphest.php b/src/applications/feed/story/PhabricatorFeedStoryManiphest.php index 6defa7c10b..aedeaf9bad 100644 --- a/src/applications/feed/story/PhabricatorFeedStoryManiphest.php +++ b/src/applications/feed/story/PhabricatorFeedStoryManiphest.php @@ -16,72 +16,48 @@ * limitations under the License. */ -final class PhabricatorFeedStoryManiphest extends PhabricatorFeedStory { +final class PhabricatorFeedStoryManiphest + extends PhabricatorFeedStory { public function getRequiredHandlePHIDs() { $data = $this->getStoryData(); return array_filter( - array( + array( $this->getStoryData()->getAuthorPHID(), $data->getValue('taskPHID'), $data->getValue('ownerPHID'), - )); + )); } public function getRequiredObjectPHIDs() { return array( $this->getStoryData()->getAuthorPHID(), - ); + ); } public function renderView() { $data = $this->getStoryData(); - $author_phid = $data->getAuthorPHID(); - $owner_phid = $data->getValue('ownerPHID'); - $task_phid = $data->getValue('taskPHID'); - - $action = $data->getValue('action'); - $view = new PhabricatorFeedStoryView(); - $verb = ManiphestAction::getActionPastTenseVerb($action); - $extra = null; - switch ($action) { - case ManiphestAction::ACTION_ASSIGN: - if ($owner_phid) { - $extra = - ' to '. - $this->linkTo($owner_phid); - } else { - $verb = 'placed'; - $extra = ' up for grabs'; - } - break; - } - - $title = - $this->linkTo($author_phid). - " {$verb} task ". - $this->linkTo($task_phid); - $title .= $extra; - $title .= '.'; - - $view->setTitle($title); - - switch ($action) { - case ManiphestAction::ACTION_CREATE: - $full_size = true; - break; - default: - $full_size = false; - break; - } - $view->setEpoch($data->getEpoch()); + $action = $this->getLineForData($data); + $view->setTitle($action); + $view->setEpoch($data->getEpoch()); + + + switch ($action) { + case ManiphestAction::ACTION_CREATE: + $full_size = true; + break; + default: + $full_size = false; + break; + } + if ($full_size) { - $view->setImage($this->getHandle($author_phid)->getImageURI()); + $view->setImage($this->getHandle($this->getAuthorPHID())->getImageURI()); $content = $this->renderSummary($data->getValue('description')); $view->appendChild($content); } else { @@ -91,4 +67,46 @@ final class PhabricatorFeedStoryManiphest extends PhabricatorFeedStory { return $view; } + + public function renderNotificationView() { + $data = $this->getStoryData(); + + $view = new PhabricatorNotificationStoryView(); + + $view->setEpoch($data->getEpoch()); + $view->setTitle($this->getLineForData($data)); + $view->setEpoch($data->getEpoch()); + $view->setViewed($this->getHasViewed()); + + return $view; + } + + private function getLineForData($data) { + $actor_phid = $data->getAuthorPHID(); + $owner_phid = $data->getValue('ownerPHID'); + $task_phid = $data->getValue('taskPHID'); + $action = $data->getValue('action'); + $description = $data->getValue('description'); + $comments = phutil_escape_html( + phutil_utf8_shorten( + $data->getValue('comments'), + 140)); + + $actor_link = $this->linkTo($actor_phid); + $task_link = $this->linkTo($task_phid); + $owner_link = $this->linkTo($owner_phid); + $verb = ManiphestAction::getActionPastTenseVerb($action); + + $one_line = "{$actor_link} {$verb} {$task_link}"; + + switch ($action) { + case ManiphestAction::ACTION_ASSIGN: + $one_line .= " to {$owner_link}"; + break; + default: + break; + } + + return $one_line; + } } diff --git a/src/applications/maniphest/controller/ManiphestTaskDetailController.php b/src/applications/maniphest/controller/ManiphestTaskDetailController.php index 8dcc699c32..b94429e7fa 100644 --- a/src/applications/maniphest/controller/ManiphestTaskDetailController.php +++ b/src/applications/maniphest/controller/ManiphestTaskDetailController.php @@ -510,6 +510,9 @@ final class ManiphestTaskDetailController extends ManiphestController { $transaction_view->setAuxiliaryFields($aux_fields); $transaction_view->setMarkupEngine($engine); + PhabricatorFeedStoryNotification::updateObjectNotificationViews( + $user, $task->getPHID()); + return $this->buildStandardPageResponse( array( $context_bar, diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 94dfa2eee3..751fa00859 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -314,7 +314,8 @@ final class ManiphestTransactionEditor { if ($transaction->hasComments()) { $comments = $transaction->getComments(); } - switch ($transaction->getTransactionType()) { + $type = $transaction->getTransactionType(); + switch ($type) { case ManiphestTransactionType::TYPE_OWNER: $actions[] = ManiphestAction::ACTION_ASSIGN; break; @@ -323,6 +324,8 @@ final class ManiphestTransactionEditor { $actions[] = ManiphestAction::ACTION_CLOSE; } else if ($this->isCreate($transactions)) { $actions[] = ManiphestAction::ACTION_CREATE; + } else { + $actions[] = ManiphestAction::ACTION_REOPEN; } break; default: @@ -335,6 +338,7 @@ final class ManiphestTransactionEditor { $actor_phid = head($transactions)->getAuthorPHID(); $author_phid = $task->getAuthorPHID(); + id(new PhabricatorFeedStoryPublisher()) ->setStoryType(PhabricatorFeedStoryTypeConstants::STORY_MANIPHEST) ->setStoryData(array( @@ -357,6 +361,17 @@ final class ManiphestTransactionEditor { $owner_phid, )), $task->getProjectPHIDs())) + ->setPrimaryObjectPHID($task->getPHID()) + ->setSubscribedPHIDs( + array_merge( + array_filter( + array( + $author_phid, + $owner_phid, + $actor_phid + ) + ), + $task->getCCPHIDs())) ->publish(); } diff --git a/src/applications/notification/PhabricatorNotificationQuery.php b/src/applications/notification/PhabricatorNotificationQuery.php new file mode 100644 index 0000000000..4b1970d4e7 --- /dev/null +++ b/src/applications/notification/PhabricatorNotificationQuery.php @@ -0,0 +1,81 @@ +limit = $limit; + return $this; + } + + public function setUserPHID($user_phid) { + $this->userPHID = $user_phid; + return $this; + } + + + public function execute() { + if (!$this->userPHID) { + throw new Exception("Call setUser() before executing the query"); + } + + //TODO throw an exception if no user + $story_table = new PhabricatorFeedStoryData(); + $notification_table = new PhabricatorFeedStoryNotification(); + + $conn = $story_table->establishConnection('r'); + + $data = queryfx_all( + $conn, + "SELECT story.*, notif.hasViewed FROM %T notif + JOIN %T story ON notif.chronologicalKey = story.chronologicalKey + WHERE notif.userPHID = %s + ORDER BY notif.chronologicalKey desc + LIMIT %d", + $notification_table->getTableName(), + $story_table->getTableName(), + $this->userPHID, + $this->limit); + + $viewed_map = ipull($data, 'hasViewed', 'chronologicalKey'); + $data = $story_table->loadAllFromArray($data); + + $stories = array(); + + foreach ($data as $story_data) { + $class = $story_data->getStoryType(); + try { + if (!class_exists($class) || + !is_subclass_of($class, 'PhabricatorFeedStory')) { + $class = 'PhabricatorFeedStoryUnknown'; + } + } catch (PhutilMissingSymbolException $ex) { + $class = 'PhabricatorFeedStoryUnknown'; + } + $story = newv($class, array($story_data)); + $story->setHasViewed($viewed_map[$story->getChronologicalKey()]); + $stories[] = $story; + } + + return $stories; + } +} diff --git a/src/applications/notification/builder/notification/PhabricatorNotificationBuilder.php b/src/applications/notification/builder/notification/PhabricatorNotificationBuilder.php new file mode 100644 index 0000000000..1e123f88ad --- /dev/null +++ b/src/applications/notification/builder/notification/PhabricatorNotificationBuilder.php @@ -0,0 +1,54 @@ +stories = $stories; + } + + public function buildView() { + + $stories = $this->stories; + + $handles = array(); + $objects = array(); + + if ($stories) { + $handle_phids = array_mergev(mpull($stories, 'getRequiredHandlePHIDs')); + $handles = id(new PhabricatorObjectHandleData($handle_phids)) + ->loadHandles(); + } + + $null_view = new AphrontNullView(); + + foreach ($stories as $story) { + $story->setHandles($handles); + $view = $story->renderNotificationView(); + $null_view->appendChild($view); + } + + return id(new AphrontNullView())->appendChild( + '
'. + $null_view->render(). + '
'); + + } +} diff --git a/src/applications/notification/controller/base/PhabricatorNotificationController.php b/src/applications/notification/controller/base/PhabricatorNotificationController.php new file mode 100644 index 0000000000..ae82ac8b10 --- /dev/null +++ b/src/applications/notification/controller/base/PhabricatorNotificationController.php @@ -0,0 +1,37 @@ +buildStandardPageView(); + + $page->setApplicationName('Notification'); + $page->setBaseURI('/notification/'); + $page->setTitle(idx($data, 'title')); + $page->setGlyph('!'); + $page->appendChild($view); + + $response = new AphrontWebpageResponse(); + return $response->setContent($page->render()); + + } + +} diff --git a/src/applications/notification/controller/test/PhabricatorNotificationTestController.php b/src/applications/notification/controller/test/PhabricatorNotificationTestController.php new file mode 100644 index 0000000000..fb7ba42300 --- /dev/null +++ b/src/applications/notification/controller/test/PhabricatorNotificationTestController.php @@ -0,0 +1,53 @@ +getRequest(); + $user = $request->getUser(); + + $query = new PhabricatorNotificationQuery(); + $query->setUserPHID($user->getPHID()); + + $stories = $query->execute(); + + $builder = new PhabricatorNotificationBuilder($stories); + $notifications_view = $builder->buildView(); + + $num_unconsumed = 0; + + foreach ($stories as $story) { + if (!$story->getHasViewed()) { + $num_unconsumed++; + } + + } + + $json = array( + $notifications_view->render() + ); + + + return $this->buildStandardPageResponse( + $json, + array('title' => 'Notification Test Page')); + } +} diff --git a/src/applications/notification/storage/PhabricatorFeedStoryNotification.php b/src/applications/notification/storage/PhabricatorFeedStoryNotification.php new file mode 100644 index 0000000000..ca2416b3aa --- /dev/null +++ b/src/applications/notification/storage/PhabricatorFeedStoryNotification.php @@ -0,0 +1,57 @@ + self::IDS_MANUAL, + self::CONFIG_TIMESTAMPS => false, + ) + parent::getConfiguration(); + } + + static public function updateObjectNotificationViews(PhabricatorUser $user, + $object_phid) { + + if (PhabricatorEnv::getEnvConfig('notification.enabled')) { + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + + $notification_table = new PhabricatorFeedStoryNotification(); + $conn = $notification_table->establishConnection('w'); + + queryfx( + $conn, + "UPDATE %T + SET hasViewed = 1 + WHERE userPHID = %s + AND primaryObjectPHID = %s + AND hasViewed = 0", + $notification_table->getTableName(), + $user->getPHID(), + $object_phid); + + unset($unguarded); + } + } + +} diff --git a/src/applications/notification/view/base/PhabricatorNotificationView.php b/src/applications/notification/view/base/PhabricatorNotificationView.php new file mode 100644 index 0000000000..5f815f7004 --- /dev/null +++ b/src/applications/notification/view/base/PhabricatorNotificationView.php @@ -0,0 +1,21 @@ +title = $title; + return $this; + } + + public function setEpoch($epoch) { + $this->epoch = $epoch; + return $this; + } + + public function setViewed($viewed) { + $this->viewed = $viewed; + } + + public function render() { + + $title = $this->title; + if (!$this->viewed) { + $title = ''.$title.''; + } + + $head = phutil_render_tag( + 'div', + array( + 'class' => 'phabricator-notification-story-head', + ), + nonempty($title, 'Untitled Story')); + + + return phutil_render_tag( + 'div', + array( + 'class' => + 'phabricator-notification '. + 'phabricator-notification-story-one-line'), + $head); + } + +}