diff --git a/src/applications/notification/builder/PhabricatorNotificationBuilder.php b/src/applications/notification/builder/PhabricatorNotificationBuilder.php index a8af4ff00e..12e4b57bfb 100644 --- a/src/applications/notification/builder/PhabricatorNotificationBuilder.php +++ b/src/applications/notification/builder/PhabricatorNotificationBuilder.php @@ -1,168 +1,181 @@ stories = $stories; } public function setUser($user) { $this->user = $user; return $this; } + public function setShowTimestamps($show_timestamps) { + $this->showTimestamps = $show_timestamps; + return $this; + } + + public function getShowTimestamps() { + return $this->showTimestamps; + } + private function parseStories() { if ($this->parsedStories) { return $this->parsedStories; } $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. // 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); $this->parsedStories = $stories; return $stories; } public function buildView() { $stories = $this->parseStories(); $null_view = new AphrontNullView(); foreach ($stories as $story) { try { $view = $story->renderView(); } catch (Exception $ex) { // TODO: Render a nice debuggable notice instead? continue; } + + $view->setShowTimestamp($this->getShowTimestamps()); + $null_view->appendChild($view->renderNotification($this->user)); } return $null_view; } public function buildDict() { $stories = $this->parseStories(); $dict = array(); $viewer = $this->user; $desktop_key = PhabricatorDesktopNotificationsSetting::SETTINGKEY; $desktop_enabled = $viewer->getUserSetting($desktop_key); foreach ($stories as $story) { if ($story instanceof PhabricatorApplicationTransactionFeedStory) { $dict[] = array( 'desktopReady' => $desktop_enabled, 'title' => $story->renderText(), 'body' => $story->renderTextBody(), 'href' => $story->getURI(), 'icon' => $story->getImageURI(), ); } else if ($story instanceof PhabricatorNotificationTestFeedStory) { $dict[] = array( 'desktopReady' => $desktop_enabled, 'title' => pht('Test Notification'), 'body' => $story->renderText(), 'href' => null, 'icon' => PhabricatorUser::getDefaultProfileImageURI(), ); } else { $dict[] = array( 'desktopReady' => false, 'title' => null, 'body' => null, 'href' => null, 'icon' => null, ); } } return $dict; } } diff --git a/src/applications/notification/controller/PhabricatorNotificationIndividualController.php b/src/applications/notification/controller/PhabricatorNotificationIndividualController.php index bfaebaf606..41dade2747 100644 --- a/src/applications/notification/controller/PhabricatorNotificationIndividualController.php +++ b/src/applications/notification/controller/PhabricatorNotificationIndividualController.php @@ -1,61 +1,62 @@ getViewer(); $stories = id(new PhabricatorNotificationQuery()) ->setViewer($viewer) ->withUserPHIDs(array($viewer->getPHID())) ->withKeys(array($request->getStr('key'))) ->execute(); if (!$stories) { return $this->buildEmptyResponse(); } $story = head($stories); if ($story->getAuthorPHID() === $viewer->getPHID()) { // Don't show the user individual notifications about their own // actions. Primarily, this stops pages from showing notifications // immediately after you click "Submit" on a comment form if the // notification server returns faster than the web server. // TODO: It would be nice to retain the "page updated" bubble on copies // of the page that are open in other tabs, but there isn't an obvious // way to do this easily. return $this->buildEmptyResponse(); } $builder = id(new PhabricatorNotificationBuilder(array($story))) - ->setUser($viewer); + ->setUser($viewer) + ->setShowTimestamps(false); $content = $builder->buildView()->render(); $dict = $builder->buildDict(); $data = $dict[0]; $response = array( 'pertinent' => true, 'primaryObjectPHID' => $story->getPrimaryObjectPHID(), 'desktopReady' => $data['desktopReady'], 'href' => $data['href'], 'icon' => $data['icon'], 'title' => $data['title'], 'body' => $data['body'], 'content' => hsprintf('%s', $content), ); return id(new AphrontAjaxResponse())->setContent($response); } private function buildEmptyResponse() { return id(new AphrontAjaxResponse())->setContent( array( 'pertinent' => false, )); } } diff --git a/src/view/phui/PHUIFeedStoryView.php b/src/view/phui/PHUIFeedStoryView.php index 8267fa0197..fbba4a3f46 100644 --- a/src/view/phui/PHUIFeedStoryView.php +++ b/src/view/phui/PHUIFeedStoryView.php @@ -1,297 +1,312 @@ tags = $tags; return $this; } public function getTags() { return $this->tags; } public function setChronologicalKey($chronological_key) { $this->chronologicalKey = $chronological_key; return $this; } public function getChronologicalKey() { return $this->chronologicalKey; } public function setTitle($title) { $this->title = $title; return $this; } public function getTitle() { return $this->title; } public function setEpoch($epoch) { $this->epoch = $epoch; return $this; } public function setImage($image) { $this->image = $image; return $this; } public function getImage() { return $this->image; } public function setImageHref($image_href) { $this->imageHref = $image_href; return $this; } public function setAppIcon($icon) { $this->appIcon = $icon; return $this; } public function setViewed($viewed) { $this->viewed = $viewed; return $this; } public function getViewed() { return $this->viewed; } public function setHref($href) { $this->href = $href; return $this; } public function setAuthorIcon($author_icon) { $this->authorIcon = $author_icon; return $this; } public function getAuthorIcon() { return $this->authorIcon; } public function setTokenBar(array $tokens) { $this->tokenBar = $tokens; return $this; } + public function setShowTimestamp($show_timestamp) { + $this->showTimestamp = $show_timestamp; + return $this; + } + + public function getShowTimestamp() { + return $this->showTimestamp; + } + public function addProject($project) { $this->projects[] = $project; return $this; } public function addAction(PHUIIconView $action) { $this->actions[] = $action; return $this; } public function setPontification($text, $title = null) { if ($title) { $title = phutil_tag('h3', array(), $title); } $copy = phutil_tag( 'div', array( 'class' => 'phui-feed-story-bigtext-post', ), array( $title, $text, )); $this->appendChild($copy); return $this; } public function getHref() { return $this->href; } public function renderNotification($user) { $classes = array( 'phabricator-notification', ); if (!$this->viewed) { $classes[] = 'phabricator-notification-unread'; } - if ($this->epoch) { - if ($user) { - $foot = phabricator_datetime($this->epoch, $user); - $foot = phutil_tag( - 'span', - array( - 'class' => 'phabricator-notification-date', - ), - $foot); + + if ($this->getShowTimestamp()) { + if ($this->epoch) { + if ($user) { + $foot = phabricator_datetime($this->epoch, $user); + $foot = phutil_tag( + 'span', + array( + 'class' => 'phabricator-notification-date', + ), + $foot); + } else { + $foot = null; + } } else { - $foot = null; + $foot = pht('No time specified.'); } } else { - $foot = pht('No time specified.'); + $foot = null; } return javelin_tag( 'div', array( 'class' => implode(' ', $classes), 'sigil' => 'notification', 'meta' => array( 'href' => $this->getHref(), ), ), array($this->title, $foot)); } public function render() { require_celerity_resource('phui-feed-story-css'); Javelin::initBehavior('phui-hovercards'); $body = null; $foot = null; $actor = new PHUIIconView(); $actor->addClass('phui-feed-story-actor'); $author_icon = $this->getAuthorIcon(); if ($this->image) { $actor->addClass('phui-feed-story-actor-image'); $actor->setImage($this->image); } else if ($author_icon) { $actor->addClass('phui-feed-story-actor-icon'); $actor->setIcon($author_icon); } if ($this->imageHref) { $actor->setHref($this->imageHref); } if ($this->epoch) { // TODO: This is really bad; when rendering through Conduit and via // renderText() we don't have a user. if ($this->hasViewer()) { $foot = phabricator_datetime($this->epoch, $this->getViewer()); } else { $foot = null; } } else { $foot = pht('No time specified.'); } if ($this->chronologicalKey) { $foot = phutil_tag( 'a', array( 'href' => '/feed/'.$this->chronologicalKey.'/', ), $foot); } $icon = null; if ($this->appIcon) { $icon = id(new PHUIIconView()) ->setIcon($this->appIcon); } $action_list = array(); $icons = null; foreach ($this->actions as $action) { $action_list[] = phutil_tag( 'li', array( 'class' => 'phui-feed-story-action-item', ), $action); } if (!empty($action_list)) { $icons = phutil_tag( 'ul', array( 'class' => 'phui-feed-story-action-list', ), $action_list); } $head = phutil_tag( 'div', array( 'class' => 'phui-feed-story-head', ), array( $actor, nonempty($this->title, pht('Untitled Story')), $icons, )); if (!empty($this->tokenBar)) { $tokenview = phutil_tag( 'div', array( 'class' => 'phui-feed-token-bar', ), $this->tokenBar); $this->appendChild($tokenview); } $body_content = $this->renderChildren(); if ($body_content) { $body = phutil_tag( 'div', array( 'class' => 'phui-feed-story-body phabricator-remarkup', ), $body_content); } $tags = null; if ($this->tags) { $tags = array( " \xC2\xB7 ", $this->tags, ); } $foot = phutil_tag( 'div', array( 'class' => 'phui-feed-story-foot', ), array( $icon, $foot, $tags, )); $classes = array('phui-feed-story'); return id(new PHUIBoxView()) ->addClass(implode(' ', $classes)) ->setBorder(true) ->addMargin(PHUI::MARGIN_MEDIUM_BOTTOM) ->appendChild(array($head, $body, $foot)); } }