diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php index 1682a7d136..4529704c2f 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php @@ -1,84 +1,103 @@ getViewer(); $xaction = id(new PhabricatorObjectQuery()) ->setViewer($viewer) ->withPHIDs(array($request->getURIData('phid'))) ->executeOne(); if (!$xaction) { return new Aphront404Response(); } if (!$xaction->getComment()) { // You can't currently edit a transaction which doesn't have a comment. // Some day you may be able to edit the visibility. return new Aphront404Response(); } if ($xaction->getComment()->getIsRemoved()) { // You can't edit history of a transaction with a removed comment. return new Aphront400Response(); } $phid = $xaction->getObjectPHID(); $handles = $viewer->loadHandles(array($phid)); $obj_handle = $handles[$phid]; $done_uri = $obj_handle->getURI(); + // If an object is locked, you can't edit comments on it. Two reasons to + // lock threads are to calm contentious issues and to freeze state for + // auditing, and editing comments serves neither goal. + + $object = $xaction->getObject(); + $can_interact = PhabricatorPolicyFilter::hasCapability( + $viewer, + $object, + PhabricatorPolicyCapability::CAN_INTERACT); + if (!$can_interact) { + return $this->newDialog() + ->setTitle(pht('Conversation Locked')) + ->appendParagraph( + pht( + 'You can not edit this comment because the conversation is '. + 'locked.')) + ->addCancelButton($done_uri); + } + if ($request->isFormOrHisecPost()) { $text = $request->getStr('text'); $comment = $xaction->getApplicationTransactionCommentObject(); $comment->setContent($text); if (!strlen($text)) { $comment->setIsDeleted(true); } $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) ->setRequest($request) ->setCancelURI($done_uri) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { return id(new AphrontReloadResponse())->setURI($done_uri); } } $errors = array(); if ($xaction->getIsMFATransaction()) { $message = pht( 'This comment was signed with MFA, so you will be required to '. 'provide MFA credentials to make changes.'); $errors[] = id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_MFA) ->setErrors(array($message)); } $form = id(new AphrontFormView()) ->setUser($viewer) ->setFullWidth(true) ->appendControl( id(new PhabricatorRemarkupControl()) ->setName('text') ->setValue($xaction->getComment()->getContent())); return $this->newDialog() ->setTitle(pht('Edit Comment')) ->appendChild($errors) ->appendForm($form) ->addSubmitButton(pht('Save Changes')) ->addCancelButton($done_uri); } } diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php index 381dfe1176..f81535e4ae 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php @@ -1,76 +1,96 @@ getViewer(); $phid = $request->getURIData('phid'); $xaction = id(new PhabricatorObjectQuery()) ->withPHIDs(array($phid)) ->setViewer($viewer) ->executeOne(); if (!$xaction) { return new Aphront404Response(); } if (!$xaction->getComment()) { return new Aphront404Response(); } if ($xaction->getComment()->getIsRemoved()) { // You can't remove an already-removed comment. return new Aphront400Response(); } $obj_phid = $xaction->getObjectPHID(); $obj_handle = id(new PhabricatorHandleQuery()) ->setViewer($viewer) ->withPHIDs(array($obj_phid)) ->executeOne(); $done_uri = $obj_handle->getURI(); + // We allow administrative removal of comments even if an object is locked, + // so you can lock a flamewar and then go clean it up. Locked threads may + // not otherwise be edited, and non-administrators can not remove comments + // from locked threads. + + $object = $xaction->getObject(); + $can_interact = PhabricatorPolicyFilter::hasCapability( + $viewer, + $object, + PhabricatorPolicyCapability::CAN_INTERACT); + if (!$can_interact && !$viewer->getIsAdmin()) { + return $this->newDialog() + ->setTitle(pht('Conversation Locked')) + ->appendParagraph( + pht( + 'You can not remove this comment because the conversation is '. + 'locked.')) + ->addCancelButton($done_uri); + } + if ($request->isFormOrHisecPost()) { $comment = $xaction->getApplicationTransactionCommentObject() ->setContent('') ->setIsRemoved(true); $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) ->setRequest($request) ->setCancelURI($done_uri) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { return id(new AphrontReloadResponse())->setURI($done_uri); } } $form = id(new AphrontFormView()) ->setUser($viewer); $dialog = $this->newDialog() ->setTitle(pht('Remove Comment')); $dialog ->appendParagraph( pht( "Removing a comment prevents anyone (including you) from reading ". "it. Removing a comment also hides the comment's edit history ". "and prevents it from being edited.")) ->appendParagraph( pht('Really remove this comment?')); $dialog ->addSubmitButton(pht('Remove Comment')) ->addCancelButton($done_uri); return $dialog; } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php index d963ea2ecb..22acb3312f 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php @@ -1,285 +1,289 @@ actingAsPHID = $acting_as_phid; return $this; } public function getActingAsPHID() { if ($this->actingAsPHID) { return $this->actingAsPHID; } return $this->getActor()->getPHID(); } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function setRequest(AphrontRequest $request) { $this->request = $request; return $this; } public function getRequest() { return $this->request; } public function setCancelURI($cancel_uri) { $this->cancelURI = $cancel_uri; return $this; } public function getCancelURI() { return $this->cancelURI; } public function setIsNewComment($is_new) { $this->isNewComment = $is_new; return $this; } public function getIsNewComment() { return $this->isNewComment; } /** * Edit a transaction's comment. This method effects the required create, * update or delete to set the transaction's comment to the provided comment. */ public function applyEdit( PhabricatorApplicationTransaction $xaction, PhabricatorApplicationTransactionComment $comment) { $this->validateEdit($xaction, $comment); $actor = $this->requireActor(); $this->applyMFAChecks($xaction, $comment); $comment->setContentSource($this->getContentSource()); $comment->setAuthorPHID($this->getActingAsPHID()); // TODO: This needs to be more sophisticated once we have meta-policies. $comment->setViewPolicy(PhabricatorPolicies::POLICY_PUBLIC); $comment->setEditPolicy($this->getActingAsPHID()); $file_phids = PhabricatorMarkupEngine::extractFilePHIDsFromEmbeddedFiles( $actor, array( $comment->getContent(), )); $xaction->openTransaction(); $xaction->beginReadLocking(); if ($xaction->getID()) { $xaction->reload(); } $new_version = $xaction->getCommentVersion() + 1; $comment->setCommentVersion($new_version); $comment->setTransactionPHID($xaction->getPHID()); $comment->save(); $old_comment = $xaction->getComment(); $comment->attachOldComment($old_comment); $xaction->setCommentVersion($new_version); $xaction->setCommentPHID($comment->getPHID()); $xaction->setViewPolicy($comment->getViewPolicy()); $xaction->setEditPolicy($comment->getEditPolicy()); $xaction->save(); $xaction->attachComment($comment); // For comment edits, we need to make sure there are no automagical // transactions like adding mentions or projects. if ($new_version > 1) { $object = id(new PhabricatorObjectQuery()) ->withPHIDs(array($xaction->getObjectPHID())) ->setViewer($this->getActor()) ->executeOne(); if ($object && $object instanceof PhabricatorApplicationTransactionInterface) { $editor = $object->getApplicationTransactionEditor(); $editor->setActor($this->getActor()); $support_xactions = $editor->getExpandedSupportTransactions( $object, $xaction); if ($support_xactions) { $editor ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($object, $support_xactions); } } } $xaction->endReadLocking(); $xaction->saveTransaction(); // Add links to any files newly referenced by the edit. if ($file_phids) { $editor = new PhabricatorEdgeEditor(); foreach ($file_phids as $file_phid) { $editor->addEdge( $xaction->getObjectPHID(), PhabricatorObjectHasFileEdgeType::EDGECONST , $file_phid); } $editor->save(); } return $this; } /** * Validate that the edit is permissible, and the actor has permission to * perform it. */ private function validateEdit( PhabricatorApplicationTransaction $xaction, PhabricatorApplicationTransactionComment $comment) { if (!$xaction->getPHID()) { throw new Exception( pht( 'Transaction must have a PHID before calling %s!', 'applyEdit()')); } $type_comment = PhabricatorTransactions::TYPE_COMMENT; if ($xaction->getTransactionType() == $type_comment) { if ($comment->getPHID()) { throw new Exception( pht('Transaction comment must not yet have a PHID!')); } } if (!$this->getContentSource()) { throw new PhutilInvalidStateException('applyEdit'); } $actor = $this->requireActor(); PhabricatorPolicyFilter::requireCapability( $actor, $xaction, PhabricatorPolicyCapability::CAN_VIEW); if ($comment->getIsRemoved() && $actor->getIsAdmin()) { // NOTE: Administrators can remove comments by any user, and don't need // to pass the edit check. } else { PhabricatorPolicyFilter::requireCapability( $actor, $xaction, PhabricatorPolicyCapability::CAN_EDIT); + PhabricatorPolicyFilter::requireCapability( + $actor, + $xaction->getObject(), + PhabricatorPolicyCapability::CAN_INTERACT); } } private function applyMFAChecks( PhabricatorApplicationTransaction $xaction, PhabricatorApplicationTransactionComment $comment) { $actor = $this->requireActor(); // We don't do any MFA checks here when you're creating a comment for the // first time (the parent editor handles them for us), so we can just bail // out if this is the creation flow. if ($this->getIsNewComment()) { return; } $request = $this->getRequest(); if (!$request) { throw new PhutilInvalidStateException('setRequest'); } $cancel_uri = $this->getCancelURI(); if (!strlen($cancel_uri)) { throw new PhutilInvalidStateException('setCancelURI'); } // If you're deleting a comment, we try to prompt you for MFA if you have // it configured, but do not require that you have it configured. In most // cases, this is administrators removing content. // See PHI1173. If you're editing a comment you authored and the original // comment was signed with MFA, you MUST have MFA on your account and you // MUST sign the edit with MFA. Otherwise, we can end up with an MFA badge // on different content than what was signed. $want_mfa = false; $need_mfa = false; if ($comment->getIsRemoved()) { // Try to prompt on removal. $want_mfa = true; } if ($xaction->getIsMFATransaction()) { if ($actor->getPHID() === $xaction->getAuthorPHID()) { // Strictly require MFA if the original transaction was signed and // you're the author. $want_mfa = true; $need_mfa = true; } } if (!$want_mfa) { return; } if ($need_mfa) { $factors = id(new PhabricatorAuthFactorConfigQuery()) ->setViewer($actor) ->withUserPHIDs(array($this->getActingAsPHID())) ->withFactorProviderStatuses( array( PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, )) ->execute(); if (!$factors) { $error = new PhabricatorApplicationTransactionValidationError( $xaction->getTransactionType(), pht('No MFA'), pht( 'This comment was signed with MFA, so edits to it must also be '. 'signed with MFA. You do not have any MFA factors attached to '. 'your account, so you can not sign this edit. Add MFA to your '. 'account in Settings.'), $xaction); throw new PhabricatorApplicationTransactionValidationException( array( $error, )); } } $workflow_key = sprintf( 'comment.edit(%s, %d)', $xaction->getPHID(), $xaction->getComment()->getID()); $hisec_token = id(new PhabricatorAuthSessionEngine()) ->setWorkflowKey($workflow_key) ->requireHighSecurityToken($actor, $request, $cancel_uri); } } diff --git a/src/applications/transactions/view/PhabricatorApplicationTransactionView.php b/src/applications/transactions/view/PhabricatorApplicationTransactionView.php index 4d738877b8..7a24bf8ff8 100644 --- a/src/applications/transactions/view/PhabricatorApplicationTransactionView.php +++ b/src/applications/transactions/view/PhabricatorApplicationTransactionView.php @@ -1,542 +1,549 @@ renderAsFeed = $feed; return $this; } public function setQuoteRef($quote_ref) { $this->quoteRef = $quote_ref; return $this; } public function getQuoteRef() { return $this->quoteRef; } public function setQuoteTargetID($quote_target_id) { $this->quoteTargetID = $quote_target_id; return $this; } public function getQuoteTargetID() { return $this->quoteTargetID; } public function setObjectPHID($object_phid) { $this->objectPHID = $object_phid; return $this; } public function getObjectPHID() { return $this->objectPHID; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setShowEditActions($show_edit_actions) { $this->showEditActions = $show_edit_actions; return $this; } public function getShowEditActions() { return $this->showEditActions; } public function setMarkupEngine(PhabricatorMarkupEngine $engine) { $this->engine = $engine; return $this; } public function setTransactions(array $transactions) { assert_instances_of($transactions, 'PhabricatorApplicationTransaction'); $this->transactions = $transactions; return $this; } public function getTransactions() { return $this->transactions; } public function setShouldTerminate($term) { $this->shouldTerminate = $term; return $this; } public function setPager(AphrontCursorPagerView $pager) { $this->pager = $pager; return $this; } public function getPager() { return $this->pager; } public function setHideCommentOptions($hide_comment_options) { $this->hideCommentOptions = $hide_comment_options; return $this; } public function getHideCommentOptions() { return $this->hideCommentOptions; } public function setViewData(array $view_data) { $this->viewData = $view_data; return $this; } public function getViewData() { return $this->viewData; } public function buildEvents($with_hiding = false) { $user = $this->getUser(); $xactions = $this->transactions; $xactions = $this->filterHiddenTransactions($xactions); $xactions = $this->groupRelatedTransactions($xactions); $groups = $this->groupDisplayTransactions($xactions); // If the viewer has interacted with this object, we hide things from // before their most recent interaction by default. This tends to make // very long threads much more manageable, because you don't have to // scroll through a lot of history and can focus on just new stuff. $show_group = null; if ($with_hiding) { // Find the most recent comment by the viewer. $group_keys = array_keys($groups); $group_keys = array_reverse($group_keys); // If we would only hide a small number of transactions, don't hide // anything. Just don't examine the last few keys. Also, we always // want to show the most recent pieces of activity, so don't examine // the first few keys either. $group_keys = array_slice($group_keys, 2, -2); $type_comment = PhabricatorTransactions::TYPE_COMMENT; foreach ($group_keys as $group_key) { $group = $groups[$group_key]; foreach ($group as $xaction) { if ($xaction->getAuthorPHID() == $user->getPHID() && $xaction->getTransactionType() == $type_comment) { // This is the most recent group where the user commented. $show_group = $group_key; break 2; } } } } $events = array(); $hide_by_default = ($show_group !== null); $set_next_page_id = false; foreach ($groups as $group_key => $group) { if ($hide_by_default && ($show_group === $group_key)) { $hide_by_default = false; $set_next_page_id = true; } $group_event = null; foreach ($group as $xaction) { $event = $this->renderEvent($xaction, $group); $event->setHideByDefault($hide_by_default); if (!$group_event) { $group_event = $event; } else { $group_event->addEventToGroup($event); } if ($set_next_page_id) { $set_next_page_id = false; $pager = $this->getPager(); if ($pager) { $pager->setNextPageID($xaction->getID()); } } } $events[] = $group_event; } return $events; } public function render() { if (!$this->getObjectPHID()) { throw new PhutilInvalidStateException('setObjectPHID'); } $view = $this->buildPHUITimelineView(); if ($this->getShowEditActions()) { Javelin::initBehavior('phabricator-transaction-list'); } return $view->render(); } public function buildPHUITimelineView($with_hiding = true) { if (!$this->getObjectPHID()) { throw new PhutilInvalidStateException('setObjectPHID'); } $view = id(new PHUITimelineView()) ->setViewer($this->getViewer()) ->setShouldTerminate($this->shouldTerminate) ->setQuoteTargetID($this->getQuoteTargetID()) ->setQuoteRef($this->getQuoteRef()) ->setViewData($this->getViewData()); $events = $this->buildEvents($with_hiding); foreach ($events as $event) { $view->addEvent($event); } if ($this->getPager()) { $view->setPager($this->getPager()); } return $view; } public function isTimelineEmpty() { return !count($this->buildEvents(true)); } protected function getOrBuildEngine() { if (!$this->engine) { $field = PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT; $engine = id(new PhabricatorMarkupEngine()) ->setViewer($this->getViewer()); foreach ($this->transactions as $xaction) { if (!$xaction->hasComment()) { continue; } $engine->addObject($xaction->getComment(), $field); } $engine->process(); $this->engine = $engine; } return $this->engine; } private function buildChangeDetailsLink( PhabricatorApplicationTransaction $xaction) { return javelin_tag( 'a', array( 'href' => $xaction->getChangeDetailsURI(), 'sigil' => 'workflow', ), pht('(Show Details)')); } private function buildExtraInformationLink( PhabricatorApplicationTransaction $xaction) { $link = $xaction->renderExtraInformationLink(); if (!$link) { return null; } return phutil_tag( 'span', array( 'class' => 'phui-timeline-extra-information', ), array(" \xC2\xB7 ", $link)); } protected function shouldGroupTransactions( PhabricatorApplicationTransaction $u, PhabricatorApplicationTransaction $v) { return false; } protected function renderTransactionContent( PhabricatorApplicationTransaction $xaction) { $field = PhabricatorApplicationTransactionComment::MARKUP_FIELD_COMMENT; $engine = $this->getOrBuildEngine(); $comment = $xaction->getComment(); if ($comment) { if ($comment->getIsRemoved()) { return javelin_tag( 'span', array( 'class' => 'comment-deleted', 'sigil' => 'transaction-comment', 'meta' => array('phid' => $comment->getTransactionPHID()), ), pht( 'This comment was removed by %s.', $xaction->getHandle($comment->getAuthorPHID())->renderLink())); } else if ($comment->getIsDeleted()) { return javelin_tag( 'span', array( 'class' => 'comment-deleted', 'sigil' => 'transaction-comment', 'meta' => array('phid' => $comment->getTransactionPHID()), ), pht('This comment has been deleted.')); } else if ($xaction->hasComment()) { return javelin_tag( 'span', array( 'class' => 'transaction-comment', 'sigil' => 'transaction-comment', 'meta' => array('phid' => $comment->getTransactionPHID()), ), $engine->getOutput($comment, $field)); } else { // This is an empty, non-deleted comment. Usually this happens when // rendering previews. return null; } } return null; } private function filterHiddenTransactions(array $xactions) { foreach ($xactions as $key => $xaction) { if ($xaction->shouldHide()) { unset($xactions[$key]); } } return $xactions; } private function groupRelatedTransactions(array $xactions) { $last = null; $last_key = null; $groups = array(); foreach ($xactions as $key => $xaction) { if ($last && $this->shouldGroupTransactions($last, $xaction)) { $groups[$last_key][] = $xaction; unset($xactions[$key]); } else { $last = $xaction; $last_key = $key; } } foreach ($xactions as $key => $xaction) { $xaction->attachTransactionGroup(idx($groups, $key, array())); } return $xactions; } private function groupDisplayTransactions(array $xactions) { $groups = array(); $group = array(); foreach ($xactions as $xaction) { if ($xaction->shouldDisplayGroupWith($group)) { $group[] = $xaction; } else { if ($group) { $groups[] = $group; } $group = array($xaction); } } if ($group) { $groups[] = $group; } foreach ($groups as $key => $group) { $results = array(); // Sort transactions within the group by action strength, then by // chronological order. This makes sure that multiple actions of the // same type (like a close, then a reopen) render in the order they // were performed. $strength_groups = mgroup($group, 'getActionStrength'); krsort($strength_groups); foreach ($strength_groups as $strength_group) { foreach (msort($strength_group, 'getID') as $xaction) { $results[] = $xaction; } } $groups[$key] = $results; } return $groups; } private function renderEvent( PhabricatorApplicationTransaction $xaction, array $group) { $viewer = $this->getViewer(); $event = id(new PHUITimelineEventView()) ->setViewer($viewer) ->setAuthorPHID($xaction->getAuthorPHID()) ->setTransactionPHID($xaction->getPHID()) ->setUserHandle($xaction->getHandle($xaction->getAuthorPHID())) ->setIcon($xaction->getIcon()) ->setColor($xaction->getColor()) ->setHideCommentOptions($this->getHideCommentOptions()) ->setIsSilent($xaction->getIsSilentTransaction()) ->setIsMFA($xaction->getIsMFATransaction()) ->setIsLockOverride($xaction->getIsLockOverrideTransaction()); list($token, $token_removed) = $xaction->getToken(); if ($token) { $event->setToken($token, $token_removed); } if (!$this->shouldSuppressTitle($xaction, $group)) { if ($this->renderAsFeed) { $title = $xaction->getTitleForFeed(); } else { $title = $xaction->getTitle(); } if ($xaction->hasChangeDetails()) { if (!$this->isPreview) { $details = $this->buildChangeDetailsLink($xaction); $title = array( $title, ' ', $details, ); } } if (!$this->isPreview) { $more = $this->buildExtraInformationLink($xaction); if ($more) { $title = array($title, ' ', $more); } } $event->setTitle($title); } if ($this->isPreview) { $event->setIsPreview(true); } else { $event ->setDateCreated($xaction->getDateCreated()) ->setContentSource($xaction->getContentSource()) ->setAnchor($xaction->getID()); } $transaction_type = $xaction->getTransactionType(); $comment_type = PhabricatorTransactions::TYPE_COMMENT; $is_normal_comment = ($transaction_type == $comment_type); if ($this->getShowEditActions() && !$this->isPreview && $is_normal_comment) { $has_deleted_comment = $xaction->getComment() && $xaction->getComment()->getIsDeleted(); $has_removed_comment = $xaction->getComment() && $xaction->getComment()->getIsRemoved(); if ($xaction->getCommentVersion() > 1 && !$has_removed_comment) { $event->setIsEdited(true); } if (!$has_removed_comment) { $event->setIsNormalComment(true); } // If we have a place for quoted text to go and this is a quotable // comment, pass the quote target ID to the event view. if ($this->getQuoteTargetID()) { if ($xaction->hasComment()) { if (!$has_removed_comment && !$has_deleted_comment) { $event->setQuoteTargetID($this->getQuoteTargetID()); $event->setQuoteRef($this->getQuoteRef()); } } } $can_edit = PhabricatorPolicyCapability::CAN_EDIT; if ($xaction->hasComment() || $has_deleted_comment) { $has_edit_capability = PhabricatorPolicyFilter::hasCapability( $viewer, $xaction, $can_edit); if ($has_edit_capability && !$has_removed_comment) { $event->setIsEditable(true); } + if ($has_edit_capability || $viewer->getIsAdmin()) { if (!$has_removed_comment) { $event->setIsRemovable(true); } } } + + $can_interact = PhabricatorPolicyFilter::hasCapability( + $viewer, + $xaction->getObject(), + PhabricatorPolicyCapability::CAN_INTERACT); + $event->setCanInteract($can_interact); } $comment = $this->renderTransactionContent($xaction); if ($comment) { $event->appendChild($comment); } return $event; } private function shouldSuppressTitle( PhabricatorApplicationTransaction $xaction, array $group) { // This is a little hard-coded, but we don't have any other reasonable // cases for now. Suppress "commented on" if there are other actions in // the display group. if (count($group) > 1) { $type_comment = PhabricatorTransactions::TYPE_COMMENT; if ($xaction->getTransactionType() == $type_comment) { return true; } } return false; } } diff --git a/src/view/phui/PHUITimelineEventView.php b/src/view/phui/PHUITimelineEventView.php index 5013611084..e4f11b2b1d 100644 --- a/src/view/phui/PHUITimelineEventView.php +++ b/src/view/phui/PHUITimelineEventView.php @@ -1,746 +1,767 @@ authorPHID = $author_phid; return $this; } public function getAuthorPHID() { return $this->authorPHID; } public function setQuoteRef($quote_ref) { $this->quoteRef = $quote_ref; return $this; } public function getQuoteRef() { return $this->quoteRef; } public function setQuoteTargetID($quote_target_id) { $this->quoteTargetID = $quote_target_id; return $this; } public function getQuoteTargetID() { return $this->quoteTargetID; } public function setIsNormalComment($is_normal_comment) { $this->isNormalComment = $is_normal_comment; return $this; } public function getIsNormalComment() { return $this->isNormalComment; } public function setHideByDefault($hide_by_default) { $this->hideByDefault = $hide_by_default; return $this; } public function getHideByDefault() { return $this->hideByDefault; } public function setTransactionPHID($transaction_phid) { $this->transactionPHID = $transaction_phid; return $this; } public function getTransactionPHID() { return $this->transactionPHID; } public function setIsEdited($is_edited) { $this->isEdited = $is_edited; return $this; } public function getIsEdited() { return $this->isEdited; } public function setIsPreview($is_preview) { $this->isPreview = $is_preview; return $this; } public function getIsPreview() { return $this->isPreview; } public function setIsEditable($is_editable) { $this->isEditable = $is_editable; return $this; } public function getIsEditable() { return $this->isEditable; } + public function setCanInteract($can_interact) { + $this->canInteract = $can_interact; + return $this; + } + + public function getCanInteract() { + return $this->canInteract; + } + public function setIsRemovable($is_removable) { $this->isRemovable = $is_removable; return $this; } public function getIsRemovable() { return $this->isRemovable; } public function setDateCreated($date_created) { $this->dateCreated = $date_created; return $this; } public function getDateCreated() { return $this->dateCreated; } public function setContentSource(PhabricatorContentSource $content_source) { $this->contentSource = $content_source; return $this; } public function getContentSource() { return $this->contentSource; } public function setUserHandle(PhabricatorObjectHandle $handle) { $this->userHandle = $handle; return $this; } public function setAnchor($anchor) { $this->anchor = $anchor; return $this; } public function getAnchor() { return $this->anchor; } public function setTitle($title) { $this->title = $title; return $this; } public function addClass($class) { $this->classes[] = $class; return $this; } public function addBadge(PHUIBadgeMiniView $badge) { $this->badges[] = $badge; return $this; } public function setIcon($icon) { $this->icon = $icon; return $this; } public function setColor($color) { $this->color = $color; return $this; } public function setIsSilent($is_silent) { $this->isSilent = $is_silent; return $this; } public function getIsSilent() { return $this->isSilent; } public function setIsMFA($is_mfa) { $this->isMFA = $is_mfa; return $this; } public function getIsMFA() { return $this->isMFA; } public function setIsLockOverride($is_override) { $this->isLockOverride = $is_override; return $this; } public function getIsLockOverride() { return $this->isLockOverride; } public function setReallyMajorEvent($me) { $this->reallyMajorEvent = $me; return $this; } public function setHideCommentOptions($hide_comment_options) { $this->hideCommentOptions = $hide_comment_options; return $this; } public function getHideCommentOptions() { return $this->hideCommentOptions; } public function addPinboardItem(PHUIPinboardItemView $item) { $this->pinboardItems[] = $item; return $this; } public function setToken($token, $removed = false) { $this->token = $token; $this->tokenRemoved = $removed; return $this; } public function getEventGroup() { return array_merge(array($this), $this->eventGroup); } public function addEventToGroup(PHUITimelineEventView $event) { $this->eventGroup[] = $event; return $this; } protected function shouldRenderEventTitle() { if ($this->title === null) { return false; } return true; } protected function renderEventTitle($force_icon, $has_menu, $extra) { $title = $this->title; $title_classes = array(); $title_classes[] = 'phui-timeline-title'; $icon = null; if ($this->icon || $force_icon) { $title_classes[] = 'phui-timeline-title-with-icon'; } if ($has_menu) { $title_classes[] = 'phui-timeline-title-with-menu'; } if ($this->icon) { $fill_classes = array(); $fill_classes[] = 'phui-timeline-icon-fill'; if ($this->color) { $fill_classes[] = 'fill-has-color'; $fill_classes[] = 'phui-timeline-icon-fill-'.$this->color; } $icon = id(new PHUIIconView()) ->setIcon($this->icon) ->addClass('phui-timeline-icon'); $icon = phutil_tag( 'span', array( 'class' => implode(' ', $fill_classes), ), $icon); } $token = null; if ($this->token) { $token = id(new PHUIIconView()) ->addClass('phui-timeline-token') ->setSpriteSheet(PHUIIconView::SPRITE_TOKENS) ->setSpriteIcon($this->token); if ($this->tokenRemoved) { $token->addClass('strikethrough'); } } $title = phutil_tag( 'div', array( 'class' => implode(' ', $title_classes), ), array($icon, $token, $title, $extra)); return $title; } public function render() { $events = $this->getEventGroup(); // Move events with icons first. $icon_keys = array(); foreach ($this->getEventGroup() as $key => $event) { if ($event->icon) { $icon_keys[] = $key; } } $events = array_select_keys($events, $icon_keys) + $events; $force_icon = (bool)$icon_keys; $menu = null; $items = array(); if (!$this->getIsPreview() && !$this->getHideCommentOptions()) { foreach ($this->getEventGroup() as $event) { $items[] = $event->getMenuItems($this->anchor); } $items = array_mergev($items); } if ($items) { $icon = id(new PHUIIconView()) ->setIcon('fa-caret-down'); $aural = javelin_tag( 'span', array( 'aural' => true, ), pht('Comment Actions')); if ($items) { $sigil = 'phui-dropdown-menu'; Javelin::initBehavior('phui-dropdown-menu'); } else { $sigil = null; } $action_list = id(new PhabricatorActionListView()) ->setUser($this->getUser()); foreach ($items as $item) { $action_list->addAction($item); } $menu = javelin_tag( $items ? 'a' : 'span', array( 'href' => '#', 'class' => 'phui-timeline-menu', 'sigil' => $sigil, 'aria-haspopup' => 'true', 'aria-expanded' => 'false', 'meta' => $action_list->getDropdownMenuMetadata(), ), array( $aural, $icon, )); $has_menu = true; } else { $has_menu = false; } // Render "extra" information (timestamp, etc). $extra = $this->renderExtra($events); $show_badges = false; $group_titles = array(); $group_items = array(); $group_children = array(); foreach ($events as $event) { if ($event->shouldRenderEventTitle()) { // Render the group anchor here, outside the title box. If we render // it inside the title box it ends up completely hidden and Chrome 55 // refuses to jump to it. See T11997 for discussion. if ($extra && $this->anchor) { $group_titles[] = id(new PhabricatorAnchorView()) ->setAnchorName($this->anchor) ->render(); } $group_titles[] = $event->renderEventTitle( $force_icon, $has_menu, $extra); // Don't render this information more than once. $extra = null; } if ($event->hasChildren()) { $group_children[] = $event->renderChildren(); $show_badges = true; } } $image_uri = $this->userHandle->getImageURI(); $wedge = phutil_tag( 'div', array( 'class' => 'phui-timeline-wedge', 'style' => (nonempty($image_uri)) ? '' : 'display: none;', ), ''); $image = null; $badges = null; if ($image_uri) { $image = javelin_tag( ($this->userHandle->getURI()) ? 'a' : 'div', array( 'style' => 'background-image: url('.$image_uri.')', 'class' => 'phui-timeline-image', 'href' => $this->userHandle->getURI(), 'aural' => false, ), ''); if ($this->badges && $show_badges) { $flex = new PHUIBadgeBoxView(); $flex->addItems($this->badges); $flex->setCollapsed(true); $badges = phutil_tag( 'div', array( 'class' => 'phui-timeline-badges', ), $flex); } } $content_classes = array(); $content_classes[] = 'phui-timeline-content'; $classes = array(); $classes[] = 'phui-timeline-event-view'; if ($group_children) { $classes[] = 'phui-timeline-major-event'; $content = phutil_tag( 'div', array( 'class' => 'phui-timeline-inner-content', ), array( $group_titles, $menu, phutil_tag( 'div', array( 'class' => 'phui-timeline-core-content', ), $group_children), )); } else { $classes[] = 'phui-timeline-minor-event'; $content = $group_titles; } $content = phutil_tag( 'div', array( 'class' => 'phui-timeline-group', ), $content); // Image Events $pinboard = null; if ($this->pinboardItems) { $pinboard = new PHUIPinboardView(); foreach ($this->pinboardItems as $item) { $pinboard->addItem($item); } } $content = phutil_tag( 'div', array( 'class' => implode(' ', $content_classes), ), array($image, $badges, $wedge, $content, $pinboard)); $outer_classes = $this->classes; $outer_classes[] = 'phui-timeline-shell'; $color = null; foreach ($this->getEventGroup() as $event) { if ($event->color) { $color = $event->color; break; } } if ($color) { $outer_classes[] = 'phui-timeline-'.$color; } $sigil = null; $meta = null; if ($this->getTransactionPHID()) { $sigil = 'transaction'; $meta = array( 'phid' => $this->getTransactionPHID(), 'anchor' => $this->anchor, ); } $major_event = null; if ($this->reallyMajorEvent) { $major_event = phutil_tag( 'div', array( 'class' => 'phui-timeline-event-view '. 'phui-timeline-spacer '. 'phui-timeline-spacer-bold', '', )); } return array( javelin_tag( 'div', array( 'class' => implode(' ', $outer_classes), 'id' => $this->anchor ? 'anchor-'.$this->anchor : null, 'sigil' => $sigil, 'meta' => $meta, ), phutil_tag( 'div', array( 'class' => implode(' ', $classes), ), $content)), $major_event, ); } private function renderExtra(array $events) { $extra = array(); if ($this->getIsPreview()) { $extra[] = pht('PREVIEW'); } else { foreach ($events as $event) { if ($event->getIsEdited()) { $extra[] = pht('Edited'); break; } } $source = $this->getContentSource(); $content_source = null; if ($source) { $content_source = id(new PhabricatorContentSourceView()) ->setContentSource($source) ->setUser($this->getUser()); $content_source = pht('Via %s', $content_source->getSourceName()); } $date_created = null; foreach ($events as $event) { if ($event->getDateCreated()) { if ($date_created === null) { $date_created = $event->getDateCreated(); } else { $date_created = min($event->getDateCreated(), $date_created); } } } if ($date_created) { $date = phabricator_datetime( $date_created, $this->getUser()); if ($this->anchor) { Javelin::initBehavior('phabricator-watch-anchor'); Javelin::initBehavior('phabricator-tooltips'); $date = array( javelin_tag( 'a', array( 'href' => '#'.$this->anchor, 'sigil' => 'has-tooltip', 'meta' => array( 'tip' => $content_source, ), ), $date), ); } $extra[] = $date; } // If this edit was applied silently, give user a hint that they should // not expect to have received any mail or notifications. if ($this->getIsSilent()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-bell-slash', 'white') ->setEmblemColor('red') ->setTooltip(pht('Silent Edit')); } // If this edit was applied while the actor was in high-security mode, // provide a hint that it was extra authentic. if ($this->getIsMFA()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-vcard', 'white') ->setEmblemColor('pink') ->setTooltip(pht('MFA Authenticated')); } if ($this->getIsLockOverride()) { $extra[] = id(new PHUIIconView()) ->setIcon('fa-chain-broken', 'white') ->setEmblemColor('violet') ->setTooltip(pht('Lock Overridden')); } } $extra = javelin_tag( 'span', array( 'class' => 'phui-timeline-extra', ), phutil_implode_html( javelin_tag( 'span', array( 'aural' => false, ), self::DELIMITER), $extra)); return $extra; } private function getMenuItems($anchor) { $xaction_phid = $this->getTransactionPHID(); + $can_interact = $this->getCanInteract(); + $viewer = $this->getViewer(); + $is_admin = $viewer->getIsAdmin(); + $items = array(); if ($this->getIsEditable()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-pencil') ->setHref('/transactions/edit/'.$xaction_phid.'/') ->setName(pht('Edit Comment')) ->addSigil('transaction-edit') + ->setDisabled(!$can_interact) ->setMetadata( array( 'anchor' => $anchor, )); } if ($this->getQuoteTargetID()) { $ref = null; if ($this->getQuoteRef()) { $ref = $this->getQuoteRef(); if ($anchor) { $ref = $ref.'#'.$anchor; } } $items[] = id(new PhabricatorActionView()) ->setIcon('fa-quote-left') ->setName(pht('Quote Comment')) ->setHref('#') ->addSigil('transaction-quote') ->setMetadata( array( 'targetID' => $this->getQuoteTargetID(), 'uri' => '/transactions/quote/'.$xaction_phid.'/', 'ref' => $ref, )); } if ($this->getIsNormalComment()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-code') ->setHref('/transactions/raw/'.$xaction_phid.'/') ->setName(pht('View Remarkup')) ->addSigil('transaction-raw') ->setMetadata( array( 'anchor' => $anchor, )); $content_source = $this->getContentSource(); $source_email = PhabricatorEmailContentSource::SOURCECONST; if ($content_source->getSource() == $source_email) { $source_id = $content_source->getContentSourceParameter('id'); if ($source_id) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-envelope-o') ->setHref('/transactions/raw/'.$xaction_phid.'/?email') ->setName(pht('View Email Body')) ->addSigil('transaction-raw') ->setMetadata( array( 'anchor' => $anchor, )); } } } if ($this->getIsEdited()) { $items[] = id(new PhabricatorActionView()) ->setIcon('fa-list') ->setHref('/transactions/history/'.$xaction_phid.'/') ->setName(pht('View Edit History')) ->setWorkflow(true); } if ($this->getIsRemovable()) { $items[] = id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER); - $items[] = id(new PhabricatorActionView()) + $remove_item = id(new PhabricatorActionView()) ->setIcon('fa-trash-o') ->setHref('/transactions/remove/'.$xaction_phid.'/') ->setName(pht('Remove Comment')) - ->setColor(PhabricatorActionView::RED) ->addSigil('transaction-remove') ->setMetadata( array( 'anchor' => $anchor, )); + if (!$is_admin && !$can_interact) { + $remove_item->setDisabled(!$is_admin && !$can_interact); + } else { + $remove_item->setColor(PhabricatorActionView::RED); + } + + $items[] = $remove_item; } return $items; } }