mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-22 14:52:41 +01:00
Support basic notification aggregation
Summary: This is both only partially complete (supports Maniphest only) and somewhat overcomplicated (includes support for applying similar algorithms to Feed), but provides runtime aggregation of notifications. Test Plan: {F13502} Reviewers: btrahan, jungejason Reviewed By: btrahan CC: aran Differential Revision: https://secure.phabricator.com/D2884
This commit is contained in:
parent
126f8e7240
commit
fc45398ba9
6 changed files with 291 additions and 1 deletions
|
@ -638,11 +638,13 @@ phutil_register_library_map(array(
|
|||
'PhabricatorFeedPublicStreamController' => 'applications/feed/controller/PhabricatorFeedPublicStreamController.php',
|
||||
'PhabricatorFeedQuery' => 'applications/feed/PhabricatorFeedQuery.php',
|
||||
'PhabricatorFeedStory' => 'applications/feed/story/PhabricatorFeedStory.php',
|
||||
'PhabricatorFeedStoryAggregate' => 'applications/feed/story/PhabricatorFeedStoryAggregate.php',
|
||||
'PhabricatorFeedStoryAudit' => 'applications/feed/story/PhabricatorFeedStoryAudit.php',
|
||||
'PhabricatorFeedStoryCommit' => 'applications/feed/story/PhabricatorFeedStoryCommit.php',
|
||||
'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php',
|
||||
'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php',
|
||||
'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php',
|
||||
'PhabricatorFeedStoryManiphestAggregate' => 'applications/feed/story/PhabricatorFeedStoryManiphestAggregate.php',
|
||||
'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php',
|
||||
'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php',
|
||||
'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php',
|
||||
|
@ -1646,11 +1648,13 @@ phutil_register_library_map(array(
|
|||
'PhabricatorFeedController' => 'PhabricatorController',
|
||||
'PhabricatorFeedDAO' => 'PhabricatorLiskDAO',
|
||||
'PhabricatorFeedPublicStreamController' => 'PhabricatorFeedController',
|
||||
'PhabricatorFeedStoryAggregate' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryAudit' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryCommit' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO',
|
||||
'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryManiphestAggregate' => 'PhabricatorFeedStoryAggregate',
|
||||
'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO',
|
||||
'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory',
|
||||
'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory',
|
||||
|
|
|
@ -139,4 +139,8 @@ abstract class PhabricatorFeedStory {
|
|||
return $text;
|
||||
}
|
||||
|
||||
public function getNotificationAggregations() {
|
||||
return array();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright 2012 Facebook, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
abstract class PhabricatorFeedStoryAggregate extends PhabricatorFeedStory {
|
||||
|
||||
private $aggregateStories = array();
|
||||
|
||||
public function getHasViewed() {
|
||||
return head($this->getAggregateStories())->getHasViewed();
|
||||
}
|
||||
|
||||
public function getRequiredHandlePHIDs() {
|
||||
$phids = array();
|
||||
foreach ($this->getAggregateStories() as $story) {
|
||||
$phids[] = $story->getRequiredHandlePHIDs();
|
||||
}
|
||||
return array_mergev($phids);
|
||||
}
|
||||
|
||||
public function getRequiredObjectPHIDs() {
|
||||
$phids = array();
|
||||
foreach ($this->getAggregateStories() as $story) {
|
||||
$phids[] = $story->getRequiredObjectPHIDs();
|
||||
}
|
||||
return array_mergev($phids);
|
||||
}
|
||||
|
||||
protected function getAuthorPHIDs() {
|
||||
$authors = array();
|
||||
foreach ($this->getAggregateStories() as $story) {
|
||||
$authors[] = $story->getStoryData()->getAuthorPHID();
|
||||
}
|
||||
return array_unique(array_filter($authors));
|
||||
}
|
||||
|
||||
protected function getDataValues($key, $default) {
|
||||
$result = array();
|
||||
foreach ($this->getAggregateStories() as $key => $story) {
|
||||
$result[$key] = $story->getStoryData()->getValue($key, $default);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
final public function setAggregateStories(array $aggregate_stories) {
|
||||
assert_instances_of($aggregate_stories, 'PhabricatorFeedStory');
|
||||
$this->aggregateStories = $aggregate_stories;
|
||||
return $this;
|
||||
}
|
||||
|
||||
final public function getAggregateStories() {
|
||||
return $this->aggregateStories;
|
||||
}
|
||||
|
||||
final public function getNotificationAggregations() {
|
||||
throw new Exception(
|
||||
"You can not get aggregations for an aggregate story.");
|
||||
}
|
||||
|
||||
}
|
|
@ -111,4 +111,19 @@ final class PhabricatorFeedStoryManiphest
|
|||
|
||||
return $one_line;
|
||||
}
|
||||
|
||||
public function getNotificationAggregations() {
|
||||
$class = get_class($this);
|
||||
$phid = $this->getStoryData()->getValue('taskPHID');
|
||||
$read = (int)$this->getHasViewed();
|
||||
|
||||
// Don't aggregate updates separated by more than 2 hours.
|
||||
$block = (int)($this->getEpoch() / (60 * 60 * 2));
|
||||
|
||||
return array(
|
||||
"{$class}:{$phid}:{$read}:{$block}"
|
||||
=> 'PhabricatorFeedStoryManiphestAggregate',
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,86 @@
|
|||
<?php
|
||||
|
||||
/*
|
||||
* Copyright 2012 Facebook, Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
final class PhabricatorFeedStoryManiphestAggregate
|
||||
extends PhabricatorFeedStoryAggregate {
|
||||
|
||||
public function renderView() {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public function renderNotificationView() {
|
||||
$data = $this->getStoryData();
|
||||
|
||||
$task_link = $this->linkTo($data->getValue('taskPHID'));
|
||||
|
||||
$authors = $this->getAuthorPHIDs();
|
||||
|
||||
// TODO: These aren't really translatable because linkTo() returns a
|
||||
// string, not an object with a gender.
|
||||
|
||||
switch (count($authors)) {
|
||||
case 1:
|
||||
$author = $this->linkTo(array_shift($authors));
|
||||
$title = pht(
|
||||
'%s made multiple updates to %s',
|
||||
$author,
|
||||
$task_link);
|
||||
break;
|
||||
case 2:
|
||||
$author1 = $this->linkTo(array_shift($authors));
|
||||
$author2 = $this->linkTo(array_shift($authors));
|
||||
$title = pht(
|
||||
'%s and %s made multiple updates to %s',
|
||||
$author1,
|
||||
$author2,
|
||||
$task_link);
|
||||
break;
|
||||
case 3:
|
||||
$author1 = $this->linkTo(array_shift($authors));
|
||||
$author2 = $this->linkTo(array_shift($authors));
|
||||
$author3 = $this->linkTo(array_shift($authors));
|
||||
$title = pht(
|
||||
'%s, %s, and %s made multiple updates to %s',
|
||||
$author1,
|
||||
$author2,
|
||||
$author3,
|
||||
$task_link);
|
||||
break;
|
||||
default:
|
||||
$author1 = $this->linkTo(array_shift($authors));
|
||||
$author2 = $this->linkTo(array_shift($authors));
|
||||
$others = count($authors);
|
||||
$title = pht(
|
||||
'%s, %s, and %d others made multiple updates to %s',
|
||||
$author1,
|
||||
$author2,
|
||||
$others,
|
||||
$task_link);
|
||||
break;
|
||||
}
|
||||
|
||||
$view = new PhabricatorNotificationStoryView();
|
||||
$view->setEpoch($this->getEpoch());
|
||||
$view->setViewed($this->getHasViewed());
|
||||
$view->setTitle($title);
|
||||
|
||||
return $view;
|
||||
}
|
||||
|
||||
}
|
|
@ -27,6 +27,114 @@ final class PhabricatorNotificationBuilder {
|
|||
public function buildView() {
|
||||
|
||||
$stories = $this->stories;
|
||||
$stories = mpull($stories, null, 'getChronologicalKey');
|
||||
|
||||
// Aggregate notifications. Generally, we can aggregate notifications only
|
||||
// by object, e.g. "a updated T123" and "b updated T123" can become
|
||||
// "a and b updated T123", but we can't combine "a updated T123" and
|
||||
// "a updated T234" into "a updated T123 and T234" because there would be
|
||||
// nowhere sensible for the notification to link to, and no reasonable way
|
||||
// to unambiguously clear it.
|
||||
|
||||
// Each notification emits keys it can aggregate on. For instance, if this
|
||||
// story is "a updated T123", it might emit a key like this:
|
||||
//
|
||||
// task:phid123:unread => PhabricatorFeedStoryManiphestAggregate
|
||||
//
|
||||
// All the unread notifications about the task with PHID "phid123" will
|
||||
// emit the same key, telling us we can aggregate them into a single
|
||||
// story of type "PhabricatorFeedStoryManiphestAggregate", which could
|
||||
// read like "a and b updated T123".
|
||||
//
|
||||
// A story might be able to aggregate in multiple ways. Although this is
|
||||
// unlikely for stories in a notification context, stories in a feed context
|
||||
// can also aggregate by actor:
|
||||
//
|
||||
// task:phid123 => PhabricatorFeedStoryManiphestAggregate
|
||||
// actor:user123 => PhabricatorFeedStoryActorAggregate
|
||||
//
|
||||
// This means the story can either become "a and b updated T123" or
|
||||
// "a updated T123 and T456". When faced with multiple possibilities, it's
|
||||
// our job to choose the best aggregation.
|
||||
//
|
||||
// For now, we use a simple greedy algorithm and repeatedly select the
|
||||
// aggregate story which consumes the largest number of individual stories
|
||||
// until no aggregate story exists that consumes more than one story.
|
||||
|
||||
|
||||
// Build up a map of all the possible aggregations.
|
||||
|
||||
$chronokey_map = array();
|
||||
$aggregation_map = array();
|
||||
$agg_types = array();
|
||||
foreach ($stories as $chronokey => $story) {
|
||||
$chronokey_map[$chronokey] = $story->getNotificationAggregations();
|
||||
foreach ($chronokey_map[$chronokey] as $key => $type) {
|
||||
$agg_types[$key] = $type;
|
||||
$aggregation_map[$key]['keys'][$chronokey] = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Repeatedly select the largest available aggregation until none remain.
|
||||
|
||||
$aggregated_stories = array();
|
||||
while ($aggregation_map) {
|
||||
|
||||
// Count the size of each aggregation, removing any which will consume
|
||||
// fewer than 2 stories.
|
||||
|
||||
foreach ($aggregation_map as $key => $dict) {
|
||||
$size = count($dict['keys']);
|
||||
if ($size > 1) {
|
||||
$aggregation_map[$key]['size'] = $size;
|
||||
} else {
|
||||
unset($aggregation_map[$key]);
|
||||
}
|
||||
}
|
||||
|
||||
// If we're out of aggregations, break out.
|
||||
|
||||
if (!$aggregation_map) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Select the aggregation we're going to make, and remove it from the
|
||||
// map.
|
||||
|
||||
$aggregation_map = isort($aggregation_map, 'size');
|
||||
$agg_info = idx(last($aggregation_map), 'keys');
|
||||
$agg_key = last_key($aggregation_map);
|
||||
unset($aggregation_map[$agg_key]);
|
||||
|
||||
// Select all the stories it aggregates, and remove them from the master
|
||||
// list of stories and from all other possible aggregations.
|
||||
|
||||
$sub_stories = array();
|
||||
foreach ($agg_info as $chronokey => $ignored) {
|
||||
$sub_stories[$chronokey] = $stories[$chronokey];
|
||||
unset($stories[$chronokey]);
|
||||
foreach ($chronokey_map[$chronokey] as $key => $type) {
|
||||
unset($aggregation_map[$key]['keys'][$chronokey]);
|
||||
}
|
||||
unset($chronokey_map[$chronokey]);
|
||||
}
|
||||
|
||||
// Build the aggregate story.
|
||||
|
||||
krsort($sub_stories);
|
||||
$story_class = $agg_types[$agg_key];
|
||||
$conv = array(head($sub_stories)->getStoryData());
|
||||
|
||||
$new_story = newv($story_class, $conv);
|
||||
$new_story->setAggregateStories($sub_stories);
|
||||
$aggregated_stories[] = $new_story;
|
||||
}
|
||||
|
||||
// Combine the aggregate stories back into the list of stories.
|
||||
|
||||
$stories = array_merge($stories, $aggregated_stories);
|
||||
$stories = mpull($stories, null, 'getChronologicalKey');
|
||||
krsort($stories);
|
||||
|
||||
$handles = array();
|
||||
$objects = array();
|
||||
|
@ -39,7 +147,6 @@ final class PhabricatorNotificationBuilder {
|
|||
|
||||
$null_view = new AphrontNullView();
|
||||
|
||||
//TODO ADD NOTIFICATIONS HEADER
|
||||
foreach ($stories as $story) {
|
||||
$story->setHandles($handles);
|
||||
$view = $story->renderNotificationView();
|
||||
|
|
Loading…
Reference in a new issue