diff --git a/src/applications/pholio/constants/PholioTransactionType.php b/src/applications/pholio/constants/PholioTransactionType.php index f540adc586..ddfd1e6a35 100644 --- a/src/applications/pholio/constants/PholioTransactionType.php +++ b/src/applications/pholio/constants/PholioTransactionType.php @@ -1,26 +1,27 @@ id = $data['id']; } public function processRequest() { $request = $this->getRequest(); $user = $request->getUser(); $mock = id(new PholioMockQuery()) ->setViewer($user) ->withIDs(array($this->id)) ->executeOne(); if (!$mock) { return new Aphront404Response(); } $xactions = id(new PholioTransactionQuery()) ->withMockIDs(array($mock->getID())) ->execute(); + $subscribers = PhabricatorSubscribersQuery::loadSubscribersForPHID( + $mock->getPHID()); $phids = array(); $phids[] = $mock->getAuthorPHID(); foreach ($xactions as $xaction) { $phids[] = $xaction->getAuthorPHID(); + foreach ($xaction->getRequiredHandlePHIDs() as $hphid) { + $phids[] = $hphid; + } + } + foreach ($subscribers as $subscriber) { + $phids[] = $subscriber; } $this->loadHandles($phids); $engine = id(new PhabricatorMarkupEngine()) ->setViewer($user); $engine->addObject($mock, PholioMock::MARKUP_FIELD_DESCRIPTION); foreach ($xactions as $xaction) { $engine->addObject($xaction, PholioTransaction::MARKUP_FIELD_COMMENT); } $engine->process(); $title = 'M'.$mock->getID().' '.$mock->getName(); $header = id(new PhabricatorHeaderView()) ->setHeader($title); $actions = $this->buildActionView($mock); - $properties = $this->buildPropertyView($mock, $engine); + $properties = $this->buildPropertyView($mock, $engine, $subscribers); $carousel = '

'. 'Carousel Goes Here

'; - $comments = - '

'. - 'Comments/Transactions Go Here

'; - $xaction_view = $this->buildTransactionView($xactions, $engine); $add_comment = $this->buildAddCommentView($mock); $content = array( $header, $actions, $properties, $carousel, $xaction_view, $add_comment, ); return $this->buildApplicationPage( $content, array( 'title' => $title, 'device' => true, )); } private function buildActionView(PholioMock $mock) { $user = $this->getRequest()->getUser(); $actions = id(new PhabricatorActionListView()) ->setUser($user) ->setObject($mock); $can_edit = PhabricatorPolicyFilter::hasCapability( $user, $mock, PhabricatorPolicyCapability::CAN_EDIT); $actions->addAction( id(new PhabricatorActionView()) ->setIcon('edit') ->setName(pht('Edit Mock')) ->setHref($this->getApplicationURI('/edit/'.$mock->getID())) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); return $actions; } private function buildPropertyView( PholioMock $mock, - PhabricatorMarkupEngine $engine) { + PhabricatorMarkupEngine $engine, + array $subscribers) { $user = $this->getRequest()->getUser(); $properties = new PhabricatorPropertyListView(); $properties->addProperty( pht('Author'), $this->getHandle($mock->getAuthorPHID())->renderLink()); $properties->addProperty( pht('Created'), phabricator_datetime($mock->getDateCreated(), $user)); $descriptions = PhabricatorPolicyQuery::renderPolicyDescriptions( $user, $mock); $properties->addProperty( pht('Visible To'), $descriptions[PhabricatorPolicyCapability::CAN_VIEW]); + if ($subscribers) { + $sub_view = array(); + foreach ($subscribers as $subscriber) { + $sub_view[] = $this->getHandle($subscriber)->renderLink(); + } + $sub_view = implode(', ', $sub_view); + } else { + $sub_view = ''.pht('None').''; + } + + $properties->addProperty( + pht('Subscribers'), + $sub_view); + $properties->addTextContent( $engine->getOutput($mock, PholioMock::MARKUP_FIELD_DESCRIPTION)); return $properties; } private function buildAddCommentView(PholioMock $mock) { $user = $this->getRequest()->getUser(); $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); $title = $is_serious ? pht('Add Comment') : pht('History Beckons'); $header = id(new PhabricatorHeaderView()) ->setHeader($title); $action = $is_serious ? pht('Add Comment') : pht('Answer The Call'); $form = id(new AphrontFormView()) ->setUser($user) ->setAction($this->getApplicationURI('/comment/'.$mock->getID().'/')) ->setWorkflow(true) ->setFlexible(true) ->appendChild( id(new PhabricatorRemarkupControl()) ->setName('comment') ->setLabel(pht('Comment'))) ->appendChild( id(new AphrontFormSubmitControl()) ->setValue($action)); return array( $header, $form, ); } private function buildTransactionView( array $xactions, PhabricatorMarkupEngine $engine) { assert_instances_of($xactions, 'PholioTransaction'); $view = new PhabricatorTimelineView(); foreach ($xactions as $xaction) { $author = $this->getHandle($xaction->getAuthorPHID()); $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); $xaction_visible = true; $title = null; + $type = $xaction->getTransactionType(); - switch ($xaction->getTransactionType()) { + switch ($type) { case PholioTransactionType::TYPE_NONE: $title = pht( '%s added a comment.', $author->renderLink()); break; case PholioTransactionType::TYPE_NAME: if ($old === null) { $xaction_visible = false; break; } $title = pht( '%s renamed this mock from "%s" to "%s".', $author->renderLink(), phutil_escape_html($old), phutil_escape_html($new)); break; case PholioTransactionType::TYPE_DESCRIPTION: if ($old === null) { $xaction_visible = false; break; } // TODO: Show diff, like Maniphest. $title = pht( '%s updated the description of this mock. '. 'The old description was: %s', $author->renderLink(), phutil_escape_html($old)); break; case PholioTransactionType::TYPE_VIEW_POLICY: if ($old === null) { $xaction_visible = false; break; } // TODO: Render human-readable. $title = pht( '%s changed the visibility of this mock from "%s" to "%s".', $author->renderLink(), phutil_escape_html($old), phutil_escape_html($new)); break; + case PholioTransactionType::TYPE_SUBSCRIBERS: + $rem = array_diff($old, $new); + $add = array_diff($new, $old); + + $add_l = array(); + foreach ($add as $phid) { + $add_l[] = $this->getHandle($phid)->renderLink(); + } + $add_l = implode(', ', $add_l); + + $rem_l = array(); + foreach ($rem as $phid) { + $rem_l[] = $this->getHandle($phid)->renderLink(); + } + $rem_l = implode(', ', $rem_l); + + if ($add && $rem) { + $title = pht( + '%s edited subscriber(s), added %d: %s; removed %d: %s.', + $author->renderLink(), + $add_l, + count($add), + $rem_l, + count($rem)); + } else if ($add) { + $title = pht( + '%s added %d subscriber(s): %s.', + $author->renderLink(), + count($add), + $add_l); + } else if ($rem) { + $title = pht( + '%s removed %d subscribers: %s.', + $author->renderLink(), + count($rem), + $rem_l); + } + break; default: throw new Exception("Unknown transaction type '{$type}'!"); } if (!$xaction_visible) { // Some transactions aren't useful to human viewers, like // the initial transactions which set the mock's name and description. continue; } $event = id(new PhabricatorTimelineEventView()) ->setUserHandle($author); $event->setTitle($title); if (strlen($xaction->getComment())) { $event->appendChild( $engine->getOutput( $xaction, PholioTransaction::MARKUP_FIELD_COMMENT)); } $view->addEvent($event); } return $view; } } diff --git a/src/applications/pholio/editor/PholioMockEditor.php b/src/applications/pholio/editor/PholioMockEditor.php index da6950be62..a1f727a65f 100644 --- a/src/applications/pholio/editor/PholioMockEditor.php +++ b/src/applications/pholio/editor/PholioMockEditor.php @@ -1,148 +1,231 @@ contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function applyTransactions(PholioMock $mock, array $xactions) { assert_instances_of($xactions, 'PholioTransaction'); $actor = $this->requireActor(); if (!$this->contentSource) { throw new Exception( "Call setContentSource() before applyTransactions()!"); } + $comments = array(); + foreach ($xactions as $xaction) { + if (strlen($xaction->getComment())) { + $comments[] = $xaction->getComment(); + } + $type = $xaction->getTransactionType(); + if ($type == PholioTransactionType::TYPE_DESCRIPTION) { + $comments[] = $xaction->getNewValue(); + } + } + + $mentioned_phids = PhabricatorMarkupEngine::extractPHIDsFromMentions( + $comments); + + if ($mentioned_phids) { + if ($mock->getID()) { + $old_subs = PhabricatorSubscribersQuery::loadSubscribersForPHID( + $mock->getPHID()); + } else { + $old_subs = array(); + } + + $new_subs = array_merge($old_subs, $mentioned_phids); + $xaction = id(new PholioTransaction()) + ->setTransactionType(PholioTransactionType::TYPE_SUBSCRIBERS) + ->setOldValue($old_subs) + ->setNewValue($new_subs); + array_unshift($xactions, $xaction); + } + foreach ($xactions as $xaction) { $xaction->setContentSource($this->contentSource); $xaction->setAuthorPHID($actor->getPHID()); } foreach ($xactions as $key => $xaction) { $has_effect = $this->applyTransaction($mock, $xaction); if (!$has_effect) { unset($xactions[$key]); } } if (!$xactions) { return; } $mock->openTransaction(); $mock->save(); foreach ($xactions as $xaction) { $xaction->setMockID($mock->getID()); $xaction->save(); } + + // Apply ID/PHID-dependent transactions. + foreach ($xactions as $xaction) { + $type = $xaction->getTransactionType(); + switch ($type) { + case PholioTransactionType::TYPE_SUBSCRIBERS: + $subeditor = id(new PhabricatorSubscriptionsEditor()) + ->setObject($mock) + ->setActor($this->requireActor()) + ->subscribeExplicit($xaction->getNewValue()) + ->save(); + break; + } + } + $mock->saveTransaction(); PholioIndexer::indexMock($mock); return $this; } private function applyTransaction( PholioMock $mock, PholioTransaction $xaction) { $type = $xaction->getTransactionType(); $old = null; switch ($type) { case PholioTransactionType::TYPE_NONE: $old = null; break; case PholioTransactionType::TYPE_NAME: $old = $mock->getName(); break; case PholioTransactionType::TYPE_DESCRIPTION: $old = $mock->getDescription(); break; case PholioTransactionType::TYPE_VIEW_POLICY: $old = $mock->getViewPolicy(); break; + case PholioTransactionType::TYPE_SUBSCRIBERS: + $old = PhabricatorSubscribersQuery::loadSubscribersForPHID( + $mock->getPHID()); + break; default: throw new Exception("Unknown transaction type '{$type}'!"); } $xaction->setOldValue($old); - if (!$this->transactionHasEffect($xaction)) { + if (!$this->transactionHasEffect($mock, $xaction)) { return false; } switch ($type) { case PholioTransactionType::TYPE_NONE: break; case PholioTransactionType::TYPE_NAME: $mock->setName($xaction->getNewValue()); break; case PholioTransactionType::TYPE_DESCRIPTION: $mock->setDescription($xaction->getNewValue()); break; case PholioTransactionType::TYPE_VIEW_POLICY: $mock->setViewPolicy($xaction->getNewValue()); break; + case PholioTransactionType::TYPE_SUBSCRIBERS: + // This applies later. + break; default: throw new Exception("Unknown transaction type '{$type}'!"); } return true; } - private function transactionHasEffect(PholioTransaction $xaction) { + private function transactionHasEffect( + PholioMock $mock, + PholioTransaction $xaction) { + $effect = false; + $old = $xaction->getOldValue(); + $new = $xaction->getNewValue(); + $type = $xaction->getTransactionType(); switch ($type) { case PholioTransactionType::TYPE_NONE: case PholioTransactionType::TYPE_NAME: case PholioTransactionType::TYPE_DESCRIPTION: case PholioTransactionType::TYPE_VIEW_POLICY: - $effect = ($xaction->getOldValue() !== $xaction->getNewValue()); + $effect = ($old !== $new); + break; + case PholioTransactionType::TYPE_SUBSCRIBERS: + $old = nonempty($old, array()); + $old_map = array_fill_keys($old, true); + $filtered = $old; + + foreach ($new as $phid) { + if ($mock->getAuthorPHID() == $phid) { + // The author may not be explicitly subscribed. + continue; + } + if (isset($old_map[$phid])) { + // This PHID was already subscribed. + continue; + } + $filtered[] = $phid; + } + + $old = array_keys($old_map); + $new = array_values($filtered); + + $xaction->setOldValue($old); + $xaction->setNewValue($new); + + $effect = ($old !== $new); break; default: throw new Exception("Unknown transaction type '{$type}'!"); } if (!$effect) { if (strlen($xaction->getComment())) { $xaction->setTransactionType(PholioTransactionType::TYPE_NONE); $effect = true; } } return $effect; } } diff --git a/src/applications/pholio/storage/PholioTransaction.php b/src/applications/pholio/storage/PholioTransaction.php index a1fd28dfd2..d7f3bbff4e 100644 --- a/src/applications/pholio/storage/PholioTransaction.php +++ b/src/applications/pholio/storage/PholioTransaction.php @@ -1,111 +1,122 @@ array( 'oldValue' => self::SERIALIZATION_JSON, 'newValue' => self::SERIALIZATION_JSON, 'metadata' => self::SERIALIZATION_JSON, ), ) + parent::getConfiguration(); } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source->serialize(); return $this; } public function getContentSource() { return PhabricatorContentSource::newFromSerialized($this->contentSource); } + public function getRequiredHandlePHIDs() { + switch ($this->getTransactionType()) { + case PholioTransactionType::TYPE_SUBSCRIBERS: + return array_merge( + $this->getOldValue(), + $this->getNewValue()); + default: + return array(); + } + } + /* -( PhabricatorSubscribableInterface Implementation )-------------------- */ public function isAutomaticallySubscribed($phid) { return ($this->authorPHID == $phid); } /* -( PhabricatorPolicyInterface Implementation )-------------------------- */ public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, PhabricatorPolicyCapability::CAN_EDIT, ); } public function getPolicy($capability) { switch ($capability) { case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_EDIT: return PhabricatorPolicies::POLICY_NOONE; } } public function hasAutomaticCapbility($capability, PhabricatorUser $viewer) { return ($viewer->getPHID() == $this->getAuthorPHID()); } /* -( PhabricatorMarkupInterface )----------------------------------------- */ public function getMarkupFieldKey($field) { return 'MX:'.$this->getID(); } public function newMarkupEngine($field) { return PhabricatorMarkupEngine::newMarkupEngine(array()); } public function getMarkupText($field) { return $this->getComment(); } public function didMarkupText($field, $output, PhutilMarkupEngine $engine) { return $output; } public function shouldUseMarkupCache($field) { return (bool)$this->getID(); } } diff --git a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php index 80bd0c4239..c17121add6 100644 --- a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php +++ b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php @@ -1,90 +1,91 @@ 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))) + ->setViewer($user) ->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()) ->setActor($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/query/PhabricatorSubscribersQuery.php b/src/applications/subscriptions/query/PhabricatorSubscribersQuery.php index c3a78a6900..0870fe27ee 100644 --- a/src/applications/subscriptions/query/PhabricatorSubscribersQuery.php +++ b/src/applications/subscriptions/query/PhabricatorSubscribersQuery.php @@ -1,50 +1,54 @@ 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; } }