diff --git a/scripts/celerity/generate_sprites.php b/scripts/celerity/generate_sprites.php index 724c41a370..c5a780480b 100755 --- a/scripts/celerity/generate_sprites.php +++ b/scripts/celerity/generate_sprites.php @@ -169,18 +169,21 @@ $action_template = id(new PhutilSprite()) ->setSourceSize(16, 16); $action_map = array( - 'file' => 'icon/page_white_text.png', - 'fork' => 'icon/arrow_branch.png', - 'edit' => 'icon/page_white_edit.png', - 'flag-0' => 'icon/flag-0.png', - 'flag-1' => 'icon/flag-1.png', - 'flag-2' => 'icon/flag-2.png', - 'flag-3' => 'icon/flag-3.png', - 'flag-4' => 'icon/flag-4.png', - 'flag-5' => 'icon/flag-5.png', - 'flag-6' => 'icon/flag-6.png', - 'flag-7' => 'icon/flag-7.png', - 'flag-ghost' => 'icon/flag-ghost.png', + 'file' => 'icon/page_white_text.png', + 'fork' => 'icon/arrow_branch.png', + 'edit' => 'icon/page_white_edit.png', + 'flag-0' => 'icon/flag-0.png', + 'flag-1' => 'icon/flag-1.png', + 'flag-2' => 'icon/flag-2.png', + 'flag-3' => 'icon/flag-3.png', + 'flag-4' => 'icon/flag-4.png', + 'flag-5' => 'icon/flag-5.png', + 'flag-6' => 'icon/flag-6.png', + 'flag-7' => 'icon/flag-7.png', + 'flag-ghost' => 'icon/flag-ghost.png', + 'subscribe-auto' => 'icon/unsubscribe.png', + 'subscribe-add' => 'icon/subscribe.png', + 'subscribe-delete' => 'icon/unsubscribe.png', ); foreach ($action_map as $icon => $source) { diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f2449925de..0de55c77f5 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -581,6 +581,7 @@ phutil_register_library_map(array( 'PhabricatorApplicationSettings' => 'applications/settings/application/PhabricatorApplicationSettings.php', 'PhabricatorApplicationSlowvote' => 'applications/slowvote/application/PhabricatorApplicationSlowvote.php', 'PhabricatorApplicationStatusView' => 'applications/meta/view/PhabricatorApplicationStatusView.php', + 'PhabricatorApplicationSubscriptions' => 'applications/subscriptions/application/PhabricatorApplicationSubscriptions.php', 'PhabricatorApplicationUIExamples' => 'applications/uiexample/application/PhabricatorApplicationUIExamples.php', 'PhabricatorApplicationsListController' => 'applications/meta/controller/PhabricatorApplicationsListController.php', 'PhabricatorAuditActionConstants' => 'applications/audit/constants/PhabricatorAuditActionConstants.php', @@ -1078,6 +1079,11 @@ phutil_register_library_map(array( 'PhabricatorStorageManagementUpgradeWorkflow' => 'infrastructure/storage/management/workflow/PhabricatorStorageManagementUpgradeWorkflow.php', 'PhabricatorStorageManagementWorkflow' => 'infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php', 'PhabricatorStoragePatch' => 'infrastructure/storage/management/PhabricatorStoragePatch.php', + 'PhabricatorSubscribableInterface' => 'applications/subscriptions/interface/PhabricatorSubscribableInterface.php', + 'PhabricatorSubscribersQuery' => 'applications/subscriptions/query/PhabricatorSubscribersQuery.php', + 'PhabricatorSubscriptionsEditController' => 'applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php', + 'PhabricatorSubscriptionsEditor' => 'applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php', + 'PhabricatorSubscriptionsUIEventListener' => 'applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php', 'PhabricatorSymbolNameLinter' => 'infrastructure/lint/hook/PhabricatorSymbolNameLinter.php', 'PhabricatorSyntaxHighlighter' => 'infrastructure/markup/PhabricatorSyntaxHighlighter.php', 'PhabricatorTaskmasterDaemon' => 'infrastructure/daemon/workers/PhabricatorTaskmasterDaemon.php', @@ -1691,6 +1697,7 @@ phutil_register_library_map(array( 'ManiphestTaskPriority' => 'ManiphestConstants', 'ManiphestTaskProject' => 'ManiphestDAO', 'ManiphestTaskProjectsView' => 'ManiphestView', + 'ManiphestTaskQuery' => 'PhabricatorQuery', 'ManiphestTaskStatus' => 'ManiphestConstants', 'ManiphestTaskSubscriber' => 'ManiphestDAO', 'ManiphestTaskSummaryView' => 'ManiphestView', @@ -1746,6 +1753,7 @@ phutil_register_library_map(array( 'PhabricatorApplicationSettings' => 'PhabricatorApplication', 'PhabricatorApplicationSlowvote' => 'PhabricatorApplication', 'PhabricatorApplicationStatusView' => 'AphrontView', + 'PhabricatorApplicationSubscriptions' => 'PhabricatorApplication', 'PhabricatorApplicationUIExamples' => 'PhabricatorApplication', 'PhabricatorApplicationsListController' => 'PhabricatorController', 'PhabricatorAuditAddCommentController' => 'PhabricatorAuditController', @@ -2185,6 +2193,9 @@ phutil_register_library_map(array( 'PhabricatorStorageManagementStatusWorkflow' => 'PhabricatorStorageManagementWorkflow', 'PhabricatorStorageManagementUpgradeWorkflow' => 'PhabricatorStorageManagementWorkflow', 'PhabricatorStorageManagementWorkflow' => 'PhutilArgumentWorkflow', + 'PhabricatorSubscribersQuery' => 'PhabricatorQuery', + 'PhabricatorSubscriptionsEditController' => 'PhabricatorController', + 'PhabricatorSubscriptionsUIEventListener' => 'PhutilEventListener', 'PhabricatorSymbolNameLinter' => 'ArcanistXHPASTLintNamingHook', 'PhabricatorTaskmasterDaemon' => 'PhabricatorDaemon', 'PhabricatorTestCase' => 'ArcanistPhutilTestCase', diff --git a/src/applications/phid/handle/PhabricatorObjectHandleData.php b/src/applications/phid/handle/PhabricatorObjectHandleData.php index 8d8767df9a..1d1beb0b98 100644 --- a/src/applications/phid/handle/PhabricatorObjectHandleData.php +++ b/src/applications/phid/handle/PhabricatorObjectHandleData.php @@ -102,6 +102,14 @@ final class PhabricatorObjectHandleData { $objects[$revision->getPHID()] = $revision; } break; + case PhabricatorPHIDConstants::PHID_TYPE_QUES: + $questions = id(new PonderQuestionQuery()) + ->withPHIDs($phids) + ->execute(); + foreach ($questions as $question) { + $objects[$question->getPHID()] = $question; + } + break; } } diff --git a/src/applications/subscriptions/application/PhabricatorApplicationSubscriptions.php b/src/applications/subscriptions/application/PhabricatorApplicationSubscriptions.php new file mode 100644 index 0000000000..0e6da7e68b --- /dev/null +++ b/src/applications/subscriptions/application/PhabricatorApplicationSubscriptions.php @@ -0,0 +1,41 @@ + array( + '(?Padd|delete)/'. + '(?P[^/]+)/' => 'PhabricatorSubscriptionsEditController', + ), + ); + } + +} + diff --git a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php new file mode 100644 index 0000000000..bcfe19a368 --- /dev/null +++ b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php @@ -0,0 +1,106 @@ +phid = idx($data, 'phid'); + $this->action = idx($data, 'action'); + } + + public function processRequest() { + $request = $this->getRequest(); + + if (!$request->isFormPost()) { + return new Aphront400Response(); + } + + switch ($this->action) { + case 'add': + $is_add = true; + break; + case 'delete': + $is_add = false; + break; + default: + return new Aphront400Response(); + } + + $user = $request->getUser(); + $phid = $this->phid; + + // TODO: This is a policy test because `loadObjects()` is not currently + // policy-aware. Once it is, we can collapse this. + $handle = PhabricatorObjectHandleData::loadOneHandle($phid, $user); + if (!$handle->isComplete()) { + return new Aphront404Response(); + } + + $objects = id(new PhabricatorObjectHandleData(array($phid))) + ->loadObjects(); + $object = idx($objects, $phid); + + if (!($object instanceof PhabricatorSubscribableInterface)) { + return $this->buildErrorResponse( + pht('Bad Object'), + pht('This object is not subscribable.'), + $handle->getURI()); + } + + if ($object->isAutomaticallySubscribed($user->getPHID())) { + return $this->buildErrorResponse( + pht('Automatically Subscribed'), + pht('You are automatically subscribed to this object.'), + $handle->getURI()); + } + + $editor = id(new PhabricatorSubscriptionsEditor()) + ->setUser($user) + ->setObject($object); + + if ($is_add) { + $editor->subscribeExplicit(array($user->getPHID()), $explicit = true); + } else { + $editor->unsubscribe(array($user->getPHID())); + } + + $editor->save(); + + // TODO: We should just render the "Unsubscribe" action and swap it out + // in the document for Ajax requests. + return id(new AphrontReloadResponse())->setURI($handle->getURI()); + } + + private function buildErrorResponse($title, $message, $uri) { + $request = $this->getRequest(); + $user = $request->getUser(); + + $dialog = id(new AphrontDialogView()) + ->setUser($user) + ->setTitle($title) + ->appendChild($message) + ->addCancelButton($uri); + + return id(new AphrontDialogResponse())->setDialog($dialog); + } + +} diff --git a/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php new file mode 100644 index 0000000000..8b01034fbf --- /dev/null +++ b/src/applications/subscriptions/editor/PhabricatorSubscriptionsEditor.php @@ -0,0 +1,128 @@ +object = $object; + return $this; + } + + public function setUser(PhabricatorUser $user) { + $this->user = $user; + return $this; + } + + + /** + * Add explicit subscribers. These subscribers have explicitly subscribed + * (or been subscribed) to the object, and will be added even if they + * had previously unsubscribed. + * + * @param list List of PHIDs to explicitly subscribe. + * @return this + */ + public function subscribeExplicit(array $phids) { + $this->explicitSubscribePHIDs += array_fill_keys($phids, true); + return $this; + } + + + /** + * Add implicit subscribers. These subscribers have taken some action which + * implicitly subscribes them (e.g., adding a comment) but it will be + * suppressed if they've previously unsubscribed from the object. + * + * @param list List of PHIDs to implicitly subscribe. + * @return this + */ + public function subscribeImplicit(array $phids) { + $this->implicitSubscribePHIDs += array_fill_keys($phids, true); + return $this; + } + + + /** + * Unsubscribe PHIDs and mark them as unsubscribed, so implicit subscriptions + * will not resubscribe them. + * + * @param list List of PHIDs to unsubscribe. + * @return this + */ + public function unsubscribe(array $phids) { + $this->unsubscribePHIDs += array_fill_keys($phids, true); + return $this; + } + + + public function save() { + if (!$this->object) { + throw new Exception('Call setObject() before save()!'); + } + if (!$this->user) { + throw new Exception('Call setUser() before save()!'); + } + + $src = $this->object->getPHID(); + + if ($this->implicitSubscribePHIDs) { + $unsub = PhabricatorEdgeQuery::loadDestinationPHIDs( + $src, + PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER); + $unsub = array_fill_keys($unsub, true); + $this->implicitSubscribePHIDs = array_diff_key( + $this->implicitSubscribePHIDs, + $unsub); + } + + $add = $this->implicitSubscribePHIDs + $this->explicitSubscribePHIDs; + $del = $this->unsubscribePHIDs; + + // If a PHID is marked for both subscription and unsubscription, treat + // unsubscription as the stronger action. + $add = array_diff_key($add, $del); + + if ($add || $del) { + $u_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_UNSUBSCRIBER; + $s_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_SUBSCRIBER; + + $editor = id(new PhabricatorEdgeEditor()) + ->setUser($this->user); + + foreach ($add as $phid => $ignored) { + $editor->removeEdge($src, $u_type, $phid); + $editor->addEdge($src, $s_type, $phid); + } + + foreach ($del as $phid => $ignored) { + $editor->removeEdge($src, $s_type, $phid); + $editor->addEdge($src, $u_type, $phid); + } + + $editor->save(); + } + } + +} diff --git a/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php b/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php new file mode 100644 index 0000000000..27c9dc8fb8 --- /dev/null +++ b/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php @@ -0,0 +1,100 @@ +listen(PhabricatorEventType::TYPE_UI_DIDRENDERACTIONS); + } + + public function handleEvent(PhutilEvent $event) { + switch ($event->getType()) { + case PhabricatorEventType::TYPE_UI_DIDRENDERACTIONS: + $this->handleActionEvent($event); + break; + } + } + + private function handleActionEvent($event) { + $user = $event->getUser(); + $object = $event->getValue('object'); + + if (!$object || !$object->getPHID()) { + // No object, or the object has no PHID yet. No way to subscribe. + return; + } + + if (!($object instanceof PhabricatorSubscribableInterface)) { + // This object isn't subscribable. + return; + } + + if ($object->isAutomaticallySubscribed($user->getPHID())) { + $sub_action = id(new PhabricatorActionView()) + ->setWorkflow(true) + ->setUser($user) + ->setDisabled(true) + ->setRenderAsForm(true) + ->setHref('/subscriptions/add/'.$object->getPHID().'/') + ->setName(phutil_escape_html('Automatically Subscribed')) + ->setIcon('subscribe-auto'); + } else { + $subscribed = false; + if ($user->isLoggedIn()) { + $src_phid = $object->getPHID(); + $dst_phid = $user->getPHID(); + $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_SUBSCRIBER; + + $edges = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs(array($src_phid)) + ->withEdgeTypes(array($edge_type)) + ->withDestinationPHIDs(array($user->getPHID())) + ->execute(); + $subscribed = isset($edges[$src_phid][$edge_type][$dst_phid]); + } + + if ($subscribed) { + $sub_action = id(new PhabricatorActionView()) + ->setUser($user) + ->setWorkflow(true) + ->setRenderAsForm(true) + ->setHref('/subscriptions/delete/'.$object->getPHID().'/') + ->setName(phutil_escape_html('Unsubscribe')) + ->setIcon('subscribe-delete'); + } else { + $sub_action = id(new PhabricatorActionView()) + ->setUser($user) + ->setWorkflow(true) + ->setRenderAsForm(true) + ->setHref('/subscriptions/add/'.$object->getPHID().'/') + ->setName(phutil_escape_html('Subscribe')) + ->setIcon('subscribe-add'); + } + + if (!$user->isLoggedIn()) { + $sub_action->setDisabled(true); + } + } + + $actions = $event->getValue('actions'); + $actions[] = $sub_action; + $event->setValue('actions', $actions); + } + +} diff --git a/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php b/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php new file mode 100644 index 0000000000..173e3b8ca4 --- /dev/null +++ b/src/applications/subscriptions/interface/PhabricatorSubscribableInterface.php @@ -0,0 +1,32 @@ +withObjectPHIDs(array($phid)) + ->execute(); + return $subscribers[$phid]; + } + + public function withObjectPHIDs(array $object_phids) { + $this->objectPHIDs = $object_phids; + return $this; + } + + public function withSubscriberPHIDs(array $subscriber_phids) { + $this->subscriberPHIDs = $subscriber_phids; + return $this; + } + + public function execute() { + $query = new PhabricatorEdgeQuery(); + + $edge_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_SUBSCRIBER; + + $query->withSourcePHIDs($this->objectPHIDs); + $query->withEdgeTypes(array($edge_type)); + + if ($this->subscriberPHIDs) { + $query->withDestinationPHIDs($this->subscriberPHIDs); + } + + $edges = $query->execute(); + + $results = array_fill_keys($this->objectPHIDs, array()); + foreach ($edges as $src => $edge_types) { + foreach ($edge_types[$edge_type] as $dst => $data) { + $results[$src][] = $dst; + } + } + + return $results; + } + + +} diff --git a/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php b/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php index 74feeaef5f..0f4a6db72e 100644 --- a/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php +++ b/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php @@ -49,6 +49,12 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { const TYPE_ANSWER_HAS_VOTING_USER = 19; const TYPE_VOTING_USER_HAS_ANSWER = 20; + const TYPE_OBJECT_HAS_SUBSCRIBER = 21; + const TYPE_SUBSCRIBED_TO_OBJECT = 22; + + const TYPE_OBJECT_HAS_UNSUBSCRIBER = 23; + const TYPE_UNSUBSCRIBED_FROM_OBJECT = 24; + const TYPE_TEST_NO_CYCLE = 9000; public static function getInverse($edge_type) { @@ -82,6 +88,12 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { self::TYPE_QUESTION_HAS_VOTING_USER, self::TYPE_ANSWER_HAS_VOTING_USER => self::TYPE_VOTING_USER_HAS_ANSWER, self::TYPE_VOTING_USER_HAS_ANSWER => self::TYPE_ANSWER_HAS_VOTING_USER, + + self::TYPE_OBJECT_HAS_SUBSCRIBER => self::TYPE_SUBSCRIBED_TO_OBJECT, + self::TYPE_SUBSCRIBED_TO_OBJECT => self::TYPE_OBJECT_HAS_SUBSCRIBER, + + self::TYPE_OBJECT_HAS_UNSUBSCRIBER => self::TYPE_UNSUBSCRIBED_FROM_OBJECT, + self::TYPE_UNSUBSCRIBED_FROM_OBJECT => self::TYPE_OBJECT_HAS_UNSUBSCRIBER, ); return idx($map, $edge_type); diff --git a/webroot/rsrc/css/autosprite.css b/webroot/rsrc/css/autosprite.css index a640820ee1..9730129455 100644 --- a/webroot/rsrc/css/autosprite.css +++ b/webroot/rsrc/css/autosprite.css @@ -363,3 +363,15 @@ .action-flag-ghost { background-position: 0px -3444px; } + +.action-subscribe-auto { + background-position: 0px -3461px; +} + +.action-subscribe-add { + background-position: 0px -3478px; +} + +.action-subscribe-delete { + background-position: 0px -3495px; +} diff --git a/webroot/rsrc/image/autosprite.png b/webroot/rsrc/image/autosprite.png index 81042a8a9b..dac2284d7d 100644 Binary files a/webroot/rsrc/image/autosprite.png and b/webroot/rsrc/image/autosprite.png differ