mirror of
https://we.phorge.it/source/phorge.git
synced 2024-11-26 16: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',
|
'PhabricatorFeedPublicStreamController' => 'applications/feed/controller/PhabricatorFeedPublicStreamController.php',
|
||||||
'PhabricatorFeedQuery' => 'applications/feed/PhabricatorFeedQuery.php',
|
'PhabricatorFeedQuery' => 'applications/feed/PhabricatorFeedQuery.php',
|
||||||
'PhabricatorFeedStory' => 'applications/feed/story/PhabricatorFeedStory.php',
|
'PhabricatorFeedStory' => 'applications/feed/story/PhabricatorFeedStory.php',
|
||||||
|
'PhabricatorFeedStoryAggregate' => 'applications/feed/story/PhabricatorFeedStoryAggregate.php',
|
||||||
'PhabricatorFeedStoryAudit' => 'applications/feed/story/PhabricatorFeedStoryAudit.php',
|
'PhabricatorFeedStoryAudit' => 'applications/feed/story/PhabricatorFeedStoryAudit.php',
|
||||||
'PhabricatorFeedStoryCommit' => 'applications/feed/story/PhabricatorFeedStoryCommit.php',
|
'PhabricatorFeedStoryCommit' => 'applications/feed/story/PhabricatorFeedStoryCommit.php',
|
||||||
'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php',
|
'PhabricatorFeedStoryData' => 'applications/feed/storage/PhabricatorFeedStoryData.php',
|
||||||
'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php',
|
'PhabricatorFeedStoryDifferential' => 'applications/feed/story/PhabricatorFeedStoryDifferential.php',
|
||||||
'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php',
|
'PhabricatorFeedStoryManiphest' => 'applications/feed/story/PhabricatorFeedStoryManiphest.php',
|
||||||
|
'PhabricatorFeedStoryManiphestAggregate' => 'applications/feed/story/PhabricatorFeedStoryManiphestAggregate.php',
|
||||||
'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php',
|
'PhabricatorFeedStoryNotification' => 'applications/notification/storage/PhabricatorFeedStoryNotification.php',
|
||||||
'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php',
|
'PhabricatorFeedStoryPhriction' => 'applications/feed/story/PhabricatorFeedStoryPhriction.php',
|
||||||
'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php',
|
'PhabricatorFeedStoryProject' => 'applications/feed/story/PhabricatorFeedStoryProject.php',
|
||||||
|
@ -1646,11 +1648,13 @@ phutil_register_library_map(array(
|
||||||
'PhabricatorFeedController' => 'PhabricatorController',
|
'PhabricatorFeedController' => 'PhabricatorController',
|
||||||
'PhabricatorFeedDAO' => 'PhabricatorLiskDAO',
|
'PhabricatorFeedDAO' => 'PhabricatorLiskDAO',
|
||||||
'PhabricatorFeedPublicStreamController' => 'PhabricatorFeedController',
|
'PhabricatorFeedPublicStreamController' => 'PhabricatorFeedController',
|
||||||
|
'PhabricatorFeedStoryAggregate' => 'PhabricatorFeedStory',
|
||||||
'PhabricatorFeedStoryAudit' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryAudit' => 'PhabricatorFeedStory',
|
||||||
'PhabricatorFeedStoryCommit' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryCommit' => 'PhabricatorFeedStory',
|
||||||
'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO',
|
'PhabricatorFeedStoryData' => 'PhabricatorFeedDAO',
|
||||||
'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryDifferential' => 'PhabricatorFeedStory',
|
||||||
'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryManiphest' => 'PhabricatorFeedStory',
|
||||||
|
'PhabricatorFeedStoryManiphestAggregate' => 'PhabricatorFeedStoryAggregate',
|
||||||
'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO',
|
'PhabricatorFeedStoryNotification' => 'PhabricatorFeedDAO',
|
||||||
'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryPhriction' => 'PhabricatorFeedStory',
|
||||||
'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory',
|
'PhabricatorFeedStoryProject' => 'PhabricatorFeedStory',
|
||||||
|
|
|
@ -139,4 +139,8 @@ abstract class PhabricatorFeedStory {
|
||||||
return $text;
|
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;
|
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() {
|
public function buildView() {
|
||||||
|
|
||||||
$stories = $this->stories;
|
$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();
|
$handles = array();
|
||||||
$objects = array();
|
$objects = array();
|
||||||
|
@ -39,7 +147,6 @@ final class PhabricatorNotificationBuilder {
|
||||||
|
|
||||||
$null_view = new AphrontNullView();
|
$null_view = new AphrontNullView();
|
||||||
|
|
||||||
//TODO ADD NOTIFICATIONS HEADER
|
|
||||||
foreach ($stories as $story) {
|
foreach ($stories as $story) {
|
||||||
$story->setHandles($handles);
|
$story->setHandles($handles);
|
||||||
$view = $story->renderNotificationView();
|
$view = $story->renderNotificationView();
|
||||||
|
|
Loading…
Reference in a new issue