diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index f8ab27945e..56bc1e7d6f 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1962,6 +1962,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'applications/project/editor/PhabricatorProjectTransactionEditor.php', 'PhabricatorProjectTransactionQuery' => 'applications/project/query/PhabricatorProjectTransactionQuery.php', 'PhabricatorProjectUpdateController' => 'applications/project/controller/PhabricatorProjectUpdateController.php', + 'PhabricatorProjectWatchController' => 'applications/project/controller/PhabricatorProjectWatchController.php', 'PhabricatorQuery' => 'infrastructure/query/PhabricatorQuery.php', 'PhabricatorRecaptchaConfigOptions' => 'applications/config/option/PhabricatorRecaptchaConfigOptions.php', 'PhabricatorRedirectController' => 'applications/base/controller/PhabricatorRedirectController.php', @@ -4776,6 +4777,7 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectUpdateController' => 'PhabricatorProjectController', + 'PhabricatorProjectWatchController' => 'PhabricatorProjectController', 'PhabricatorRecaptchaConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorRedirectController' => 'PhabricatorController', 'PhabricatorRefreshCSRFController' => 'PhabricatorAuthController', diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 41485b2ac9..9eda17b7ee 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -333,6 +333,10 @@ final class ManiphestTransactionEditor $phids[] = $phid; } + foreach (parent::getMailCC($object) as $phid) { + $phids[] = $phid; + } + foreach ($this->heraldEmailPHIDs as $phid) { $phids[] = $phid; } diff --git a/src/applications/project/application/PhabricatorApplicationProject.php b/src/applications/project/application/PhabricatorApplicationProject.php index c6a1d92e12..fe5108327e 100644 --- a/src/applications/project/application/PhabricatorApplicationProject.php +++ b/src/applications/project/application/PhabricatorApplicationProject.php @@ -62,6 +62,8 @@ final class PhabricatorApplicationProject extends PhabricatorApplication { 'update/(?P[1-9]\d*)/(?P[^/]+)/' => 'PhabricatorProjectUpdateController', 'history/(?P[1-9]\d*)/' => 'PhabricatorProjectHistoryController', + '(?Pwatch|unwatch)/(?P[1-9]\d*)/' + => 'PhabricatorProjectWatchController', ), ); } diff --git a/src/applications/project/controller/PhabricatorProjectProfileController.php b/src/applications/project/controller/PhabricatorProjectProfileController.php index 3586db683f..688f78ccd6 100644 --- a/src/applications/project/controller/PhabricatorProjectProfileController.php +++ b/src/applications/project/controller/PhabricatorProjectProfileController.php @@ -21,6 +21,7 @@ final class PhabricatorProjectProfileController ->setViewer($user) ->withIDs(array($this->id)) ->needMembers(true) + ->needWatchers(true) ->needImages(true) ->executeOne(); if (!$project) { @@ -222,14 +223,32 @@ final class PhabricatorProjectProfileController ->setIcon('fa-plus') ->setDisabled(!$can_join) ->setName(pht('Join Project')); + $view->addAction($action); } else { $action = id(new PhabricatorActionView()) ->setWorkflow(true) ->setHref('/project/update/'.$project->getID().'/leave/') ->setIcon('fa-times') ->setName(pht('Leave Project...')); + $view->addAction($action); + + if (!$project->isUserWatcher($viewer->getPHID())) { + $action = id(new PhabricatorActionView()) + ->setWorkflow(true) + ->setHref('/project/watch/'.$project->getID().'/') + ->setIcon('fa-eye') + ->setName(pht('Watch Project')); + $view->addAction($action); + } else { + $action = id(new PhabricatorActionView()) + ->setWorkflow(true) + ->setHref('/project/unwatch/'.$project->getID().'/') + ->setIcon('fa-eye-slash') + ->setName(pht('Unwatch Project')); + $view->addAction($action); + } } - $view->addAction($action); + return $view; } @@ -240,7 +259,10 @@ final class PhabricatorProjectProfileController $request = $this->getRequest(); $viewer = $request->getUser(); - $this->loadHandles($project->getMemberPHIDs()); + $this->loadHandles( + array_merge( + $project->getMemberPHIDs(), + $project->getWatcherPHIDs())); $view = id(new PHUIPropertyListView()) ->setUser($viewer) @@ -250,8 +272,14 @@ final class PhabricatorProjectProfileController $view->addProperty( pht('Members'), $project->getMemberPHIDs() - ? $this->renderHandlesForPHIDs($project->getMemberPHIDs(), ',') - : phutil_tag('em', array(), pht('None'))); + ? $this->renderHandlesForPHIDs($project->getMemberPHIDs(), ',') + : phutil_tag('em', array(), pht('None'))); + + $view->addProperty( + pht('Watchers'), + $project->getWatcherPHIDs() + ? $this->renderHandlesForPHIDs($project->getWatcherPHIDs(), ',') + : phutil_tag('em', array(), pht('None'))); $field_list = PhabricatorCustomField::getObjectFields( $project, diff --git a/src/applications/project/controller/PhabricatorProjectWatchController.php b/src/applications/project/controller/PhabricatorProjectWatchController.php new file mode 100644 index 0000000000..78ae3b568a --- /dev/null +++ b/src/applications/project/controller/PhabricatorProjectWatchController.php @@ -0,0 +1,97 @@ +id = $data['id']; + $this->action = $data['action']; + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $project = id(new PhabricatorProjectQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->needMembers(true) + ->needWatchers(true) + ->executeOne(); + if (!$project) { + return new Aphront404Response(); + } + + $project_uri = '/project/view/'.$project->getID().'/'; + + // You must be a member of a project to + if (!$project->isUserMember($viewer->getPHID())) { + return new Aphront400Response(); + } + + if ($request->isDialogFormPost()) { + $edge_action = null; + switch ($this->action) { + case 'watch': + $edge_action = '+'; + $force_subscribe = true; + break; + case 'unwatch': + $edge_action = '-'; + $force_subscribe = false; + break; + } + + $type_member = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; + $member_spec = array( + $edge_action => array($viewer->getPHID() => $viewer->getPHID()), + ); + + $xactions = array(); + $xactions[] = id(new PhabricatorProjectTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) + ->setMetadataValue('edge:type', $type_member) + ->setNewValue($member_spec); + + $editor = id(new PhabricatorProjectTransactionEditor($project)) + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->applyTransactions($project, $xactions); + + return id(new AphrontRedirectResponse())->setURI($project_uri); + } + + $dialog = null; + switch ($this->action) { + case 'watch': + $title = pht('Watch Project?'); + $body = pht( + 'Watching a project will let you monitor it closely. You will '. + 'receive email and notifications about changes to every object '. + 'associated with projects you watch.'); + $submit = pht('Watch Project'); + break; + case 'unwatch': + $title = pht('Unwatch Project?'); + $body = pht( + 'You will no longer receive email or notifications about every '. + 'object associated with this project.'); + $submit = pht('Unwatch Project'); + break; + default: + return new Aphront404Response(); + } + + return $this->newDialog() + ->setTitle($title) + ->appendParagraph($body) + ->addCancelButton($project_uri) + ->addSubmitButton($submit); + } + +} diff --git a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php index 82c17697f6..e6f44e52c6 100644 --- a/src/applications/project/editor/PhabricatorProjectTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectTransactionEditor.php @@ -125,14 +125,24 @@ final class PhabricatorProjectTransactionEditor case PhabricatorProjectTransaction::TYPE_IMAGE: return; case PhabricatorTransactions::TYPE_EDGE: - switch ($xaction->getMetadataValue('edge:type')) { + $edge_type = $xaction->getMetadataValue('edge:type'); + switch ($edge_type) { case PhabricatorEdgeConfig::TYPE_PROJ_MEMBER: - // When project members are added or removed, add or remove their - // subscriptions. + case PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER: $old = $xaction->getOldValue(); $new = $xaction->getNewValue(); + + // When adding members or watchers, we add subscriptions. $add = array_keys(array_diff_key($new, $old)); - $rem = array_keys(array_diff_key($old, $new)); + + // When removing members, we remove their subscription too. + // When unwatching, we leave subscriptions, since it's fine to be + // subscribed to a project but not be a member of it. + if ($edge_type == PhabricatorEdgeConfig::TYPE_PROJ_MEMBER) { + $rem = array_keys(array_diff_key($old, $new)); + } else { + $rem = array(); + } // NOTE: The subscribe is "explicit" because there's no implicit // unsubscribe, so Join -> Leave -> Join doesn't resubscribe you @@ -142,12 +152,28 @@ final class PhabricatorProjectTransactionEditor // this, which is a fairly weird edge case and pretty arguable both // ways. + // Subscriptions caused by watches should also clearly be explicit, + // and that case is unambiguous. + id(new PhabricatorSubscriptionsEditor()) ->setActor($this->requireActor()) ->setObject($object) ->subscribeExplicit($add) ->unsubscribe($rem) ->save(); + + if ($rem) { + // When removing members, also remove any watches on the project. + $edge_editor = id(new PhabricatorEdgeEditor()) + ->setSuppressEvents(true); + foreach ($rem as $rem_phid) { + $edge_editor->removeEdge( + $object->getPHID(), + PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER, + $rem_phid); + } + $edge_editor->save(); + } break; } return; diff --git a/src/applications/project/query/PhabricatorProjectQuery.php b/src/applications/project/query/PhabricatorProjectQuery.php index 52dfeb5368..5ee82af365 100644 --- a/src/applications/project/query/PhabricatorProjectQuery.php +++ b/src/applications/project/query/PhabricatorProjectQuery.php @@ -17,6 +17,7 @@ final class PhabricatorProjectQuery const STATUS_ARCHIVED = 'status-archived'; private $needMembers; + private $needWatchers; private $needImages; public function withIDs(array $ids) { @@ -54,6 +55,11 @@ final class PhabricatorProjectQuery return $this; } + public function needWatchers($need_watchers) { + $this->needWatchers = $need_watchers; + return $this; + } + public function needImages($need_images) { $this->needImages = $need_images; return $this; @@ -100,19 +106,14 @@ final class PhabricatorProjectQuery if ($projects) { $viewer_phid = $this->getViewer()->getPHID(); + $project_phids = mpull($projects, 'getPHID'); + + $member_type = PhabricatorEdgeConfig::TYPE_PROJ_MEMBER; + $watcher_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; + + $need_edge_types = array(); if ($this->needMembers) { - $etype = PhabricatorEdgeConfig::TYPE_PROJ_MEMBER; - $members = id(new PhabricatorEdgeQuery()) - ->withSourcePHIDs(mpull($projects, 'getPHID')) - ->withEdgeTypes(array($etype)) - ->execute(); - foreach ($projects as $project) { - $phid = $project->getPHID(); - $project->attachMemberPHIDs(array_keys($members[$phid][$etype])); - $project->setIsUserMember( - $viewer_phid, - isset($members[$phid][$etype][$viewer_phid])); - } + $need_edge_types[] = $member_type; } else { foreach ($data as $row) { $projects[$row['id']]->setIsUserMember( @@ -120,6 +121,39 @@ final class PhabricatorProjectQuery ($row['viewerIsMember'] !== null)); } } + + if ($this->needWatchers) { + $need_edge_types[] = $watcher_type; + } + + if ($need_edge_types) { + $edges = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs($project_phids) + ->withEdgeTypes($need_edge_types) + ->execute(); + + if ($this->needMembers) { + foreach ($projects as $project) { + $phid = $project->getPHID(); + $project->attachMemberPHIDs( + array_keys($edges[$phid][$member_type])); + $project->setIsUserMember( + $viewer_phid, + isset($edges[$phid][$member_type][$viewer_phid])); + } + } + + if ($this->needWatchers) { + foreach ($projects as $project) { + $phid = $project->getPHID(); + $project->attachWatcherPHIDs( + array_keys($edges[$phid][$watcher_type])); + $project->setIsUserWatcher( + $viewer_phid, + isset($edges[$phid][$watcher_type][$viewer_phid])); + } + } + } } return $projects; diff --git a/src/applications/project/storage/PhabricatorProject.php b/src/applications/project/storage/PhabricatorProject.php index c0979186cb..5adb8600ae 100644 --- a/src/applications/project/storage/PhabricatorProject.php +++ b/src/applications/project/storage/PhabricatorProject.php @@ -19,6 +19,8 @@ final class PhabricatorProject extends PhabricatorProjectDAO protected $joinPolicy; private $memberPHIDs = self::ATTACHABLE; + private $watcherPHIDs = self::ATTACHABLE; + private $sparseWatchers = self::ATTACHABLE; private $sparseMembers = self::ATTACHABLE; private $customFields = self::ATTACHABLE; private $profileImageFile = self::ATTACHABLE; @@ -159,6 +161,32 @@ final class PhabricatorProject extends PhabricatorProjectDAO } + public function isUserWatcher($user_phid) { + if ($this->watcherPHIDs !== self::ATTACHABLE) { + return in_array($user_phid, $this->watcherPHIDs); + } + return $this->assertAttachedKey($this->sparseWatchers, $user_phid); + } + + public function setIsUserWatcher($user_phid, $is_watcher) { + if ($this->sparseWatchers === self::ATTACHABLE) { + $this->sparseWatchers = array(); + } + $this->sparseWatchers[$user_phid] = $is_watcher; + return $this; + } + + public function attachWatcherPHIDs(array $phids) { + $this->watcherPHIDs = $phids; + return $this; + } + + public function getWatcherPHIDs() { + return $this->assertAttached($this->watcherPHIDs); + } + + + /* -( PhabricatorSubscribableInterface )----------------------------------- */ @@ -171,7 +199,8 @@ final class PhabricatorProject extends PhabricatorProjectDAO } public function shouldAllowSubscription($phid) { - return $this->isUserMember($phid); + return $this->isUserMember($phid) && + !$this->isUserWatcher($phid); } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index ff1399fd46..6bf0585651 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -1891,10 +1891,65 @@ abstract class PhabricatorApplicationTransactionEditor * @task mail */ protected function getMailCC(PhabricatorLiskDAO $object) { + $phids = array(); + $has_support = false; + if ($object instanceof PhabricatorSubscribableInterface) { - return $this->subscribers; + $phids[] = $this->subscribers; + $has_support = true; } - throw new Exception("Capability not supported."); + + // TODO: This should be some interface which specifies that the object + // has project associations. + if ($object instanceof ManiphestTask) { + + // TODO: This is what normal objects would do, but Maniphest is still + // behind the times. + if (false) { + $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( + $object->getPHID(), + PhabricatorEdgeConfig::TYPE_OBJECT_HAS_PROJECT); + } else { + $project_phids = $object->getProjectPHIDs(); + } + + if ($project_phids) { + $watcher_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; + + $query = id(new PhabricatorEdgeQuery()) + ->withSourcePHIDs($project_phids) + ->withEdgeTypes(array($watcher_type)); + $query->execute(); + + $watcher_phids = $query->getDestinationPHIDs(); + + // We need to do a visibility check for all the watchers, as + // watching a project is not a guarantee that you can see objects + // associated with it. + $users = id(new PhabricatorPeopleQuery()) + ->setViewer($this->requireActor()) + ->withPHIDs($watcher_phids) + ->execute(); + + foreach ($users as $user) { + $can_see = PhabricatorPolicyFilter::hasCapability( + $user, + $object, + PhabricatorPolicyCapability::CAN_VIEW); + if ($can_see) { + $phids[] = $user->getPHID(); + } + } + } + + $has_support = true; + } + + if (!$has_support) { + throw new Exception('Capability not supported.'); + } + + return array_mergev($phids); } diff --git a/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php b/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php index 823d668c8b..91283a6aee 100644 --- a/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php +++ b/src/infrastructure/edges/constants/PhabricatorEdgeConfig.php @@ -72,6 +72,9 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { const TYPE_DASHBOARD_HAS_PANEL = 45; const TYPE_PANEL_HAS_DASHBOARD = 46; + const TYPE_OBJECT_HAS_WATCHER = 47; + const TYPE_WATCHER_HAS_OBJECT = 48; + const TYPE_TEST_NO_CYCLE = 9000; const TYPE_PHOB_HAS_ASANATASK = 80001; @@ -159,6 +162,9 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { self::TYPE_PANEL_HAS_DASHBOARD => self::TYPE_DASHBOARD_HAS_PANEL, self::TYPE_DASHBOARD_HAS_PANEL => self::TYPE_PANEL_HAS_DASHBOARD, + + self::TYPE_OBJECT_HAS_WATCHER => self::TYPE_WATCHER_HAS_OBJECT, + self::TYPE_WATCHER_HAS_OBJECT => self::TYPE_OBJECT_HAS_WATCHER ); return idx($map, $edge_type); @@ -343,6 +349,8 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { return '%s added %d panel(s): %s.'; case self::TYPE_PANEL_HAS_DASHBOARD: return '%s added %d dashboard(s): %s.'; + case self::TYPE_OBJECT_HAS_WATCHER: + return '%s added %d watcher(s): %s.'; case self::TYPE_SUBSCRIBED_TO_OBJECT: case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: case self::TYPE_FILE_HAS_OBJECT: @@ -418,6 +426,8 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { return '%s removed %d panel(s): %s.'; case self::TYPE_PANEL_HAS_DASHBOARD: return '%s removed %d dashboard(s): %s.'; + case self::TYPE_OBJECT_HAS_WATCHER: + return '%s removed %d watcher(s): %s.'; case self::TYPE_SUBSCRIBED_TO_OBJECT: case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: case self::TYPE_FILE_HAS_OBJECT: @@ -491,6 +501,8 @@ final class PhabricatorEdgeConfig extends PhabricatorEdgeConstants { return '%s updated panels for %s.'; case self::TYPE_PANEL_HAS_DASHBOARD: return '%s updated dashboards for %s.'; + case self::TYPE_OBJECT_HAS_WATCHER: + return '%s updated watchers for %s.'; case self::TYPE_SUBSCRIBED_TO_OBJECT: case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: case self::TYPE_FILE_HAS_OBJECT: