@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Allow projects to be "watched", sort of a super-subscribe

Summary:
Ref T4967. Adds a "Watch" relationship to projects, which is stronger than member/subscribed.

Specifically, when a task is tagged with a project, we'll include all project watchers in the email/notifications. Normally we don't include projects unless they're explicitly CC'd, or have some other active role in the object (like being a reviewer or auditor).

This allows you to closely follow a project without needing to write a Herald rule for every project you care about.

Test Plan:
- Watched/unwatched a project.
- Tested the watch/subscribe/member relationships:
- Watching implies subscribe.
- Joining implies subscribe.
- Leaving implies unsubscribe + unwatch.
- You can't unsubscribe until you unwatch (slightly better would be unsubscribe implies unwatch, but this is a bit tricky).
- Watched a project, then recevied email about a tagged task without otherwise being involved.

Reviewers: btrahan

Reviewed By: btrahan

Subscribers: epriestley

Maniphest Tasks: T4967

Differential Revision: https://secure.phabricator.com/D9185

+312 -23
+2
src/__phutil_library_map__.php
··· 1962 1962 'PhabricatorProjectTransactionEditor' => 'applications/project/editor/PhabricatorProjectTransactionEditor.php', 1963 1963 'PhabricatorProjectTransactionQuery' => 'applications/project/query/PhabricatorProjectTransactionQuery.php', 1964 1964 'PhabricatorProjectUpdateController' => 'applications/project/controller/PhabricatorProjectUpdateController.php', 1965 + 'PhabricatorProjectWatchController' => 'applications/project/controller/PhabricatorProjectWatchController.php', 1965 1966 'PhabricatorQuery' => 'infrastructure/query/PhabricatorQuery.php', 1966 1967 'PhabricatorRecaptchaConfigOptions' => 'applications/config/option/PhabricatorRecaptchaConfigOptions.php', 1967 1968 'PhabricatorRedirectController' => 'applications/base/controller/PhabricatorRedirectController.php', ··· 4776 4777 'PhabricatorProjectTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 4777 4778 'PhabricatorProjectTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 4778 4779 'PhabricatorProjectUpdateController' => 'PhabricatorProjectController', 4780 + 'PhabricatorProjectWatchController' => 'PhabricatorProjectController', 4779 4781 'PhabricatorRecaptchaConfigOptions' => 'PhabricatorApplicationConfigOptions', 4780 4782 'PhabricatorRedirectController' => 'PhabricatorController', 4781 4783 'PhabricatorRefreshCSRFController' => 'PhabricatorAuthController',
+4
src/applications/maniphest/editor/ManiphestTransactionEditor.php
··· 333 333 $phids[] = $phid; 334 334 } 335 335 336 + foreach (parent::getMailCC($object) as $phid) { 337 + $phids[] = $phid; 338 + } 339 + 336 340 foreach ($this->heraldEmailPHIDs as $phid) { 337 341 $phids[] = $phid; 338 342 }
+2
src/applications/project/application/PhabricatorApplicationProject.php
··· 62 62 'update/(?P<id>[1-9]\d*)/(?P<action>[^/]+)/' 63 63 => 'PhabricatorProjectUpdateController', 64 64 'history/(?P<id>[1-9]\d*)/' => 'PhabricatorProjectHistoryController', 65 + '(?P<action>watch|unwatch)/(?P<id>[1-9]\d*)/' 66 + => 'PhabricatorProjectWatchController', 65 67 ), 66 68 ); 67 69 }
+32 -4
src/applications/project/controller/PhabricatorProjectProfileController.php
··· 21 21 ->setViewer($user) 22 22 ->withIDs(array($this->id)) 23 23 ->needMembers(true) 24 + ->needWatchers(true) 24 25 ->needImages(true) 25 26 ->executeOne(); 26 27 if (!$project) { ··· 222 223 ->setIcon('fa-plus') 223 224 ->setDisabled(!$can_join) 224 225 ->setName(pht('Join Project')); 226 + $view->addAction($action); 225 227 } else { 226 228 $action = id(new PhabricatorActionView()) 227 229 ->setWorkflow(true) 228 230 ->setHref('/project/update/'.$project->getID().'/leave/') 229 231 ->setIcon('fa-times') 230 232 ->setName(pht('Leave Project...')); 233 + $view->addAction($action); 234 + 235 + if (!$project->isUserWatcher($viewer->getPHID())) { 236 + $action = id(new PhabricatorActionView()) 237 + ->setWorkflow(true) 238 + ->setHref('/project/watch/'.$project->getID().'/') 239 + ->setIcon('fa-eye') 240 + ->setName(pht('Watch Project')); 241 + $view->addAction($action); 242 + } else { 243 + $action = id(new PhabricatorActionView()) 244 + ->setWorkflow(true) 245 + ->setHref('/project/unwatch/'.$project->getID().'/') 246 + ->setIcon('fa-eye-slash') 247 + ->setName(pht('Unwatch Project')); 248 + $view->addAction($action); 249 + } 231 250 } 232 - $view->addAction($action); 251 + 233 252 234 253 return $view; 235 254 } ··· 240 259 $request = $this->getRequest(); 241 260 $viewer = $request->getUser(); 242 261 243 - $this->loadHandles($project->getMemberPHIDs()); 262 + $this->loadHandles( 263 + array_merge( 264 + $project->getMemberPHIDs(), 265 + $project->getWatcherPHIDs())); 244 266 245 267 $view = id(new PHUIPropertyListView()) 246 268 ->setUser($viewer) ··· 250 272 $view->addProperty( 251 273 pht('Members'), 252 274 $project->getMemberPHIDs() 253 - ? $this->renderHandlesForPHIDs($project->getMemberPHIDs(), ',') 254 - : phutil_tag('em', array(), pht('None'))); 275 + ? $this->renderHandlesForPHIDs($project->getMemberPHIDs(), ',') 276 + : phutil_tag('em', array(), pht('None'))); 277 + 278 + $view->addProperty( 279 + pht('Watchers'), 280 + $project->getWatcherPHIDs() 281 + ? $this->renderHandlesForPHIDs($project->getWatcherPHIDs(), ',') 282 + : phutil_tag('em', array(), pht('None'))); 255 283 256 284 $field_list = PhabricatorCustomField::getObjectFields( 257 285 $project,
+97
src/applications/project/controller/PhabricatorProjectWatchController.php
··· 1 + <?php 2 + 3 + final class PhabricatorProjectWatchController 4 + extends PhabricatorProjectController { 5 + 6 + private $id; 7 + private $action; 8 + 9 + public function willProcessRequest(array $data) { 10 + $this->id = $data['id']; 11 + $this->action = $data['action']; 12 + } 13 + 14 + public function processRequest() { 15 + $request = $this->getRequest(); 16 + $viewer = $request->getUser(); 17 + 18 + $project = id(new PhabricatorProjectQuery()) 19 + ->setViewer($viewer) 20 + ->withIDs(array($this->id)) 21 + ->needMembers(true) 22 + ->needWatchers(true) 23 + ->executeOne(); 24 + if (!$project) { 25 + return new Aphront404Response(); 26 + } 27 + 28 + $project_uri = '/project/view/'.$project->getID().'/'; 29 + 30 + // You must be a member of a project to 31 + if (!$project->isUserMember($viewer->getPHID())) { 32 + return new Aphront400Response(); 33 + } 34 + 35 + if ($request->isDialogFormPost()) { 36 + $edge_action = null; 37 + switch ($this->action) { 38 + case 'watch': 39 + $edge_action = '+'; 40 + $force_subscribe = true; 41 + break; 42 + case 'unwatch': 43 + $edge_action = '-'; 44 + $force_subscribe = false; 45 + break; 46 + } 47 + 48 + $type_member = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; 49 + $member_spec = array( 50 + $edge_action => array($viewer->getPHID() => $viewer->getPHID()), 51 + ); 52 + 53 + $xactions = array(); 54 + $xactions[] = id(new PhabricatorProjectTransaction()) 55 + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 56 + ->setMetadataValue('edge:type', $type_member) 57 + ->setNewValue($member_spec); 58 + 59 + $editor = id(new PhabricatorProjectTransactionEditor($project)) 60 + ->setActor($viewer) 61 + ->setContentSourceFromRequest($request) 62 + ->setContinueOnNoEffect(true) 63 + ->setContinueOnMissingFields(true) 64 + ->applyTransactions($project, $xactions); 65 + 66 + return id(new AphrontRedirectResponse())->setURI($project_uri); 67 + } 68 + 69 + $dialog = null; 70 + switch ($this->action) { 71 + case 'watch': 72 + $title = pht('Watch Project?'); 73 + $body = pht( 74 + 'Watching a project will let you monitor it closely. You will '. 75 + 'receive email and notifications about changes to every object '. 76 + 'associated with projects you watch.'); 77 + $submit = pht('Watch Project'); 78 + break; 79 + case 'unwatch': 80 + $title = pht('Unwatch Project?'); 81 + $body = pht( 82 + 'You will no longer receive email or notifications about every '. 83 + 'object associated with this project.'); 84 + $submit = pht('Unwatch Project'); 85 + break; 86 + default: 87 + return new Aphront404Response(); 88 + } 89 + 90 + return $this->newDialog() 91 + ->setTitle($title) 92 + ->appendParagraph($body) 93 + ->addCancelButton($project_uri) 94 + ->addSubmitButton($submit); 95 + } 96 + 97 + }
+30 -4
src/applications/project/editor/PhabricatorProjectTransactionEditor.php
··· 125 125 case PhabricatorProjectTransaction::TYPE_IMAGE: 126 126 return; 127 127 case PhabricatorTransactions::TYPE_EDGE: 128 - switch ($xaction->getMetadataValue('edge:type')) { 128 + $edge_type = $xaction->getMetadataValue('edge:type'); 129 + switch ($edge_type) { 129 130 case PhabricatorEdgeConfig::TYPE_PROJ_MEMBER: 130 - // When project members are added or removed, add or remove their 131 - // subscriptions. 131 + case PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER: 132 132 $old = $xaction->getOldValue(); 133 133 $new = $xaction->getNewValue(); 134 + 135 + // When adding members or watchers, we add subscriptions. 134 136 $add = array_keys(array_diff_key($new, $old)); 135 - $rem = array_keys(array_diff_key($old, $new)); 137 + 138 + // When removing members, we remove their subscription too. 139 + // When unwatching, we leave subscriptions, since it's fine to be 140 + // subscribed to a project but not be a member of it. 141 + if ($edge_type == PhabricatorEdgeConfig::TYPE_PROJ_MEMBER) { 142 + $rem = array_keys(array_diff_key($old, $new)); 143 + } else { 144 + $rem = array(); 145 + } 136 146 137 147 // NOTE: The subscribe is "explicit" because there's no implicit 138 148 // unsubscribe, so Join -> Leave -> Join doesn't resubscribe you ··· 142 152 // this, which is a fairly weird edge case and pretty arguable both 143 153 // ways. 144 154 155 + // Subscriptions caused by watches should also clearly be explicit, 156 + // and that case is unambiguous. 157 + 145 158 id(new PhabricatorSubscriptionsEditor()) 146 159 ->setActor($this->requireActor()) 147 160 ->setObject($object) 148 161 ->subscribeExplicit($add) 149 162 ->unsubscribe($rem) 150 163 ->save(); 164 + 165 + if ($rem) { 166 + // When removing members, also remove any watches on the project. 167 + $edge_editor = id(new PhabricatorEdgeEditor()) 168 + ->setSuppressEvents(true); 169 + foreach ($rem as $rem_phid) { 170 + $edge_editor->removeEdge( 171 + $object->getPHID(), 172 + PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER, 173 + $rem_phid); 174 + } 175 + $edge_editor->save(); 176 + } 151 177 break; 152 178 } 153 179 return;
+46 -12
src/applications/project/query/PhabricatorProjectQuery.php
··· 17 17 const STATUS_ARCHIVED = 'status-archived'; 18 18 19 19 private $needMembers; 20 + private $needWatchers; 20 21 private $needImages; 21 22 22 23 public function withIDs(array $ids) { ··· 51 52 52 53 public function needMembers($need_members) { 53 54 $this->needMembers = $need_members; 55 + return $this; 56 + } 57 + 58 + public function needWatchers($need_watchers) { 59 + $this->needWatchers = $need_watchers; 54 60 return $this; 55 61 } 56 62 ··· 100 106 101 107 if ($projects) { 102 108 $viewer_phid = $this->getViewer()->getPHID(); 109 + $project_phids = mpull($projects, 'getPHID'); 110 + 111 + $member_type = PhabricatorEdgeConfig::TYPE_PROJ_MEMBER; 112 + $watcher_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; 113 + 114 + $need_edge_types = array(); 103 115 if ($this->needMembers) { 104 - $etype = PhabricatorEdgeConfig::TYPE_PROJ_MEMBER; 105 - $members = id(new PhabricatorEdgeQuery()) 106 - ->withSourcePHIDs(mpull($projects, 'getPHID')) 107 - ->withEdgeTypes(array($etype)) 108 - ->execute(); 109 - foreach ($projects as $project) { 110 - $phid = $project->getPHID(); 111 - $project->attachMemberPHIDs(array_keys($members[$phid][$etype])); 112 - $project->setIsUserMember( 113 - $viewer_phid, 114 - isset($members[$phid][$etype][$viewer_phid])); 115 - } 116 + $need_edge_types[] = $member_type; 116 117 } else { 117 118 foreach ($data as $row) { 118 119 $projects[$row['id']]->setIsUserMember( 119 120 $viewer_phid, 120 121 ($row['viewerIsMember'] !== null)); 122 + } 123 + } 124 + 125 + if ($this->needWatchers) { 126 + $need_edge_types[] = $watcher_type; 127 + } 128 + 129 + if ($need_edge_types) { 130 + $edges = id(new PhabricatorEdgeQuery()) 131 + ->withSourcePHIDs($project_phids) 132 + ->withEdgeTypes($need_edge_types) 133 + ->execute(); 134 + 135 + if ($this->needMembers) { 136 + foreach ($projects as $project) { 137 + $phid = $project->getPHID(); 138 + $project->attachMemberPHIDs( 139 + array_keys($edges[$phid][$member_type])); 140 + $project->setIsUserMember( 141 + $viewer_phid, 142 + isset($edges[$phid][$member_type][$viewer_phid])); 143 + } 144 + } 145 + 146 + if ($this->needWatchers) { 147 + foreach ($projects as $project) { 148 + $phid = $project->getPHID(); 149 + $project->attachWatcherPHIDs( 150 + array_keys($edges[$phid][$watcher_type])); 151 + $project->setIsUserWatcher( 152 + $viewer_phid, 153 + isset($edges[$phid][$watcher_type][$viewer_phid])); 154 + } 121 155 } 122 156 } 123 157 }
+30 -1
src/applications/project/storage/PhabricatorProject.php
··· 19 19 protected $joinPolicy; 20 20 21 21 private $memberPHIDs = self::ATTACHABLE; 22 + private $watcherPHIDs = self::ATTACHABLE; 23 + private $sparseWatchers = self::ATTACHABLE; 22 24 private $sparseMembers = self::ATTACHABLE; 23 25 private $customFields = self::ATTACHABLE; 24 26 private $profileImageFile = self::ATTACHABLE; ··· 159 161 } 160 162 161 163 164 + public function isUserWatcher($user_phid) { 165 + if ($this->watcherPHIDs !== self::ATTACHABLE) { 166 + return in_array($user_phid, $this->watcherPHIDs); 167 + } 168 + return $this->assertAttachedKey($this->sparseWatchers, $user_phid); 169 + } 170 + 171 + public function setIsUserWatcher($user_phid, $is_watcher) { 172 + if ($this->sparseWatchers === self::ATTACHABLE) { 173 + $this->sparseWatchers = array(); 174 + } 175 + $this->sparseWatchers[$user_phid] = $is_watcher; 176 + return $this; 177 + } 178 + 179 + public function attachWatcherPHIDs(array $phids) { 180 + $this->watcherPHIDs = $phids; 181 + return $this; 182 + } 183 + 184 + public function getWatcherPHIDs() { 185 + return $this->assertAttached($this->watcherPHIDs); 186 + } 187 + 188 + 189 + 162 190 /* -( PhabricatorSubscribableInterface )----------------------------------- */ 163 191 164 192 ··· 171 199 } 172 200 173 201 public function shouldAllowSubscription($phid) { 174 - return $this->isUserMember($phid); 202 + return $this->isUserMember($phid) && 203 + !$this->isUserWatcher($phid); 175 204 } 176 205 177 206
+57 -2
src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php
··· 1891 1891 * @task mail 1892 1892 */ 1893 1893 protected function getMailCC(PhabricatorLiskDAO $object) { 1894 + $phids = array(); 1895 + $has_support = false; 1896 + 1894 1897 if ($object instanceof PhabricatorSubscribableInterface) { 1895 - return $this->subscribers; 1898 + $phids[] = $this->subscribers; 1899 + $has_support = true; 1900 + } 1901 + 1902 + // TODO: This should be some interface which specifies that the object 1903 + // has project associations. 1904 + if ($object instanceof ManiphestTask) { 1905 + 1906 + // TODO: This is what normal objects would do, but Maniphest is still 1907 + // behind the times. 1908 + if (false) { 1909 + $project_phids = PhabricatorEdgeQuery::loadDestinationPHIDs( 1910 + $object->getPHID(), 1911 + PhabricatorEdgeConfig::TYPE_OBJECT_HAS_PROJECT); 1912 + } else { 1913 + $project_phids = $object->getProjectPHIDs(); 1914 + } 1915 + 1916 + if ($project_phids) { 1917 + $watcher_type = PhabricatorEdgeConfig::TYPE_OBJECT_HAS_WATCHER; 1918 + 1919 + $query = id(new PhabricatorEdgeQuery()) 1920 + ->withSourcePHIDs($project_phids) 1921 + ->withEdgeTypes(array($watcher_type)); 1922 + $query->execute(); 1923 + 1924 + $watcher_phids = $query->getDestinationPHIDs(); 1925 + 1926 + // We need to do a visibility check for all the watchers, as 1927 + // watching a project is not a guarantee that you can see objects 1928 + // associated with it. 1929 + $users = id(new PhabricatorPeopleQuery()) 1930 + ->setViewer($this->requireActor()) 1931 + ->withPHIDs($watcher_phids) 1932 + ->execute(); 1933 + 1934 + foreach ($users as $user) { 1935 + $can_see = PhabricatorPolicyFilter::hasCapability( 1936 + $user, 1937 + $object, 1938 + PhabricatorPolicyCapability::CAN_VIEW); 1939 + if ($can_see) { 1940 + $phids[] = $user->getPHID(); 1941 + } 1942 + } 1943 + } 1944 + 1945 + $has_support = true; 1896 1946 } 1897 - throw new Exception("Capability not supported."); 1947 + 1948 + if (!$has_support) { 1949 + throw new Exception('Capability not supported.'); 1950 + } 1951 + 1952 + return array_mergev($phids); 1898 1953 } 1899 1954 1900 1955
+12
src/infrastructure/edges/constants/PhabricatorEdgeConfig.php
··· 72 72 const TYPE_DASHBOARD_HAS_PANEL = 45; 73 73 const TYPE_PANEL_HAS_DASHBOARD = 46; 74 74 75 + const TYPE_OBJECT_HAS_WATCHER = 47; 76 + const TYPE_WATCHER_HAS_OBJECT = 48; 77 + 75 78 const TYPE_TEST_NO_CYCLE = 9000; 76 79 77 80 const TYPE_PHOB_HAS_ASANATASK = 80001; ··· 159 162 160 163 self::TYPE_PANEL_HAS_DASHBOARD => self::TYPE_DASHBOARD_HAS_PANEL, 161 164 self::TYPE_DASHBOARD_HAS_PANEL => self::TYPE_PANEL_HAS_DASHBOARD, 165 + 166 + self::TYPE_OBJECT_HAS_WATCHER => self::TYPE_WATCHER_HAS_OBJECT, 167 + self::TYPE_WATCHER_HAS_OBJECT => self::TYPE_OBJECT_HAS_WATCHER 162 168 ); 163 169 164 170 return idx($map, $edge_type); ··· 343 349 return '%s added %d panel(s): %s.'; 344 350 case self::TYPE_PANEL_HAS_DASHBOARD: 345 351 return '%s added %d dashboard(s): %s.'; 352 + case self::TYPE_OBJECT_HAS_WATCHER: 353 + return '%s added %d watcher(s): %s.'; 346 354 case self::TYPE_SUBSCRIBED_TO_OBJECT: 347 355 case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: 348 356 case self::TYPE_FILE_HAS_OBJECT: ··· 418 426 return '%s removed %d panel(s): %s.'; 419 427 case self::TYPE_PANEL_HAS_DASHBOARD: 420 428 return '%s removed %d dashboard(s): %s.'; 429 + case self::TYPE_OBJECT_HAS_WATCHER: 430 + return '%s removed %d watcher(s): %s.'; 421 431 case self::TYPE_SUBSCRIBED_TO_OBJECT: 422 432 case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: 423 433 case self::TYPE_FILE_HAS_OBJECT: ··· 491 501 return '%s updated panels for %s.'; 492 502 case self::TYPE_PANEL_HAS_DASHBOARD: 493 503 return '%s updated dashboards for %s.'; 504 + case self::TYPE_OBJECT_HAS_WATCHER: 505 + return '%s updated watchers for %s.'; 494 506 case self::TYPE_SUBSCRIBED_TO_OBJECT: 495 507 case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: 496 508 case self::TYPE_FILE_HAS_OBJECT: