Commit 60ba036f authored by catch's avatar catch

Issue #1991684 by Wim Leers, rcaracaus, amateescu, msonnabaum: Fixed Node...

Issue #1991684 by Wim Leers, rcaracaus, amateescu, msonnabaum: Fixed Node history markers (comment & node 'new' indicator, 'x new comments' links) forces render caching to be per user.
parent ff8fa899
......@@ -525,14 +525,15 @@ function comment_node_view(EntityInterface $node, EntityDisplay $display, $view_
'fragment' => 'comments',
'html' => TRUE,
);
// Show a link to the first new comment.
if ($new = comment_num_new($node->id())) {
if (Drupal::moduleHandler()->moduleExists('history')) {
$links['comment-new-comments'] = array(
'title' => format_plural($new, '1 new comment', '@count new comments'),
'href' => 'node/' . $node->id(),
'query' => comment_new_page_count($node->comment_count, $new, $node),
'attributes' => array('title' => t('Jump to the first new comment of this posting.')),
'fragment' => 'new',
'title' => '',
'href' => '',
'attributes' => array(
'class' => 'hidden',
'title' => t('Jump to the first new comment of this posting.'),
'data-history-node-last-comment-timestamp' => $node->last_comment_timestamp,
),
'html' => TRUE,
);
}
......@@ -603,6 +604,9 @@ function comment_node_view(EntityInterface $node, EntityDisplay $display, $view_
'#links' => $links,
'#attributes' => array('class' => array('links', 'inline')),
);
if ($view_mode == 'teaser' && Drupal::moduleHandler()->moduleExists('history')) {
$node->content['links']['#attached']['library'][] = array('comment', 'drupal.node-new-comments-link');
}
// Only append comments when we are building a node on its own node detail
// page. We compare $node and $page_node to ensure that comments are not
......@@ -614,6 +618,15 @@ function comment_node_view(EntityInterface $node, EntityDisplay $display, $view_
}
}
/**
* Implements hook_node_view_alter().
*/
function comment_node_view_alter(&$build, EntityInterface $node, EntityDisplay $display) {
if (Drupal::moduleHandler()->moduleExists('history')) {
$build['#attributes']['data-history-node-id'] = $node->id();
}
}
/**
* Builds the comment-related elements for node detail pages.
*
......@@ -797,20 +810,10 @@ function comment_get_thread(EntityInterface $node, $mode, $comments_per_page) {
* An array of comment objects, keyed by comment ID.
*/
function comment_prepare_thread(&$comments) {
// A flag stating if we are still searching for first new comment on the thread.
$first_new = TRUE;
// A counter that helps track how indented we are.
$divs = 0;
foreach ($comments as $key => $comment) {
if ($first_new && $comment->new->value != MARK_READ) {
// Assign the anchor only for the first new comment. This avoids duplicate
// id attributes on a page.
$first_new = FALSE;
$comment->first_new = TRUE;
}
// The $divs element instructs #prefix whether to add an indent div or
// close existing divs (a negative value).
$comment->depth = count(explode('.', $comment->thread->value)) - 1;
......@@ -1499,7 +1502,7 @@ function template_preprocess_comment(&$variables) {
'#account' => $account,
);
$variables['author'] = drupal_render($username);
$variables['new'] = $comment->new->value ? t('new') : '';
$variables['new_indicator_timestamp'] = $comment->changed->value;
$variables['created'] = format_date($comment->created->value);
// Avoid calling format_date() twice on the same timestamp.
if ($comment->changed->value == $comment->created->value) {
......@@ -1591,9 +1594,6 @@ function template_preprocess_comment(&$variables) {
if ($variables['status'] != 'published') {
$variables['attributes']['class'][] = $variables['status'];
}
if ($variables['new']) {
$variables['attributes']['class'][] = 'new';
}
if (!$comment->uid->target_id) {
$variables['attributes']['class'][] = 'by-anonymous';
}
......@@ -1601,13 +1601,13 @@ function template_preprocess_comment(&$variables) {
if ($comment->uid->target_id == $variables['node']->getAuthorId()) {
$variables['attributes']['class'][] = 'by-node-author';
}
if ($comment->uid->target_id == $variables['user']->id()) {
$variables['attributes']['class'][] = 'by-viewer';
}
}
// Add clearfix class.
$variables['attributes']['class'][] = 'clearfix';
// Add comment author user ID. Necessary for the comment-by-viewer library.
$variables['attributes']['data-comment-user-id'] = $comment->uid->value;
$variables['content_attributes']['class'][] = 'content';
}
......@@ -1772,11 +1772,12 @@ function comment_file_download_access($field, EntityInterface $entity, File $fil
* Implements hook_library_info().
*/
function comment_library_info() {
$path = drupal_get_path('module', 'comment');
$libraries['drupal.comment'] = array(
'title' => 'Comment',
'version' => Drupal::VERSION,
'js' => array(
drupal_get_path('module', 'comment') . '/comment-node-form.js' => array(),
$path . '/comment-node-form.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
......@@ -1784,6 +1785,42 @@ function comment_library_info() {
array('system', 'drupal.form'),
),
);
$libraries['drupal.comment-by-viewer'] = array(
'title' => 'Annotate comments by the current viewer for targeted styling',
'version' => Drupal::VERSION,
'js' => array(
$path . '/js/comment-by-viewer.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
array('system', 'drupalSettings'),
),
);
$libraries['drupal.comment-new-indicator'] = array(
'title' => 'New comment indicator',
'version' => Drupal::VERSION,
'js' => array(
$path . '/js/comment-new-indicator.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
array('history', 'drupal.history'),
array('system', 'drupal.displace'),
),
);
$libraries['drupal.node-new-comments-link'] = array(
'title' => 'New comments link',
'version' => Drupal::VERSION,
'js' => array(
$path . '/js/node-new-comments-link.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupal'),
array('history', 'drupal.history'),
),
);
return $libraries;
}
......@@ -34,3 +34,10 @@ comment_reply:
pid: ~
requirements:
_entity_access: 'node.view'
comment_new_comments_node_links:
pattern: '/comments/render_new_comments_node_links'
defaults:
_controller: '\Drupal\comment\Controller\CommentController::renderNewCommentsNodeLinks'
requirements:
_permission: 'access content'
/**
* Attaches behaviors for the Comment module's "by-viewer" class.
*/
(function ($, Drupal, drupalSettings) {
"use strict";
/**
* Add 'by-viewer' class to comments written by the current user.
*/
Drupal.behaviors.commentByViewer = {
attach: function (context) {
var currentUserID = parseInt(drupalSettings.user.uid, 10);
$('[data-comment-user-id]')
.filter(function () {
return parseInt(this.getAttribute('data-comment-user-id'), 10) === currentUserID;
})
.addClass('by-viewer');
}
};
})(jQuery, Drupal, drupalSettings);
/**
* Attaches behaviors for the Comment module's "new" indicator.
*
* May only be loaded for authenticated users, with the History module enabled.
*/
(function ($, Drupal, window) {
"use strict";
/**
* Render "new" comment indicators wherever necessary.
*/
Drupal.behaviors.commentNewIndicator = {
attach: function (context) {
// Collect all "new" comment indicator placeholders (and their corresponding
// node IDs) newer than 30 days ago that have not already been read after
// their last comment timestamp.
var nodeIDs = [];
var $placeholders = $(context)
.find('[data-comment-timestamp]')
.once('history')
.filter(function () {
var $placeholder = $(this);
var commentTimestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10);
var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id');
if (Drupal.history.needsServerCheck(nodeID, commentTimestamp)) {
nodeIDs.push(nodeID);
return true;
}
else {
return false;
}
});
if ($placeholders.length === 0) {
return;
}
// Fetch the node read timestamps from the server.
Drupal.history.fetchTimestamps(nodeIDs, function () {
processCommentNewIndicators($placeholders);
});
}
};
function processCommentNewIndicators($placeholders) {
var isFirstNewComment = true;
var newCommentString = Drupal.t('new');
var $placeholder;
$placeholders.each(function (index, placeholder) {
$placeholder = $(placeholder);
var timestamp = parseInt($placeholder.attr('data-comment-timestamp'), 10);
var $node = $placeholder.closest('[data-history-node-id]');
var nodeID = $node.attr('data-history-node-id');
var lastViewTimestamp = Drupal.history.getLastRead(nodeID);
if (timestamp > lastViewTimestamp) {
// Turn the placeholder into an actual "new" indicator.
var $comment = $(placeholder)
.removeClass('hidden')
.text(newCommentString)
.closest('.comment')
// Add 'new' class to the comment, so it can be styled.
.addClass('new');
// Insert "new" anchor just before the "comment-<cid>" anchor if
// this is the first new comment in the DOM.
if (isFirstNewComment) {
isFirstNewComment = false;
$comment.prev().before('<a id="new" />');
// If the URL points to the first new comment, then scroll to that
// comment.
if (window.location.hash === '#new') {
window.scrollTo(0, $comment.offset().top - Drupal.displace().top);
}
}
}
});
}
})(jQuery, Drupal, window);
/**
* Attaches behaviors for the Comment module's "X new comments" link.
*
* May only be loaded for authenticated users, with the History module enabled.
*/
(function ($, Drupal) {
"use strict";
/**
* Render "X new comments" links wherever necessary.
*/
Drupal.behaviors.nodeNewCommentsLink = {
attach: function (context) {
// Collect all "X new comments" node link placeholders (and their
// corresponding node IDs) newer than 30 days ago that have not already been
// read after their last comment timestamp.
var nodeIDs = [];
var $placeholders = $(context)
.find('[data-history-node-last-comment-timestamp]')
.once('history')
.filter(function () {
var $placeholder = $(this);
var lastCommentTimestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10);
var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id');
if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) {
nodeIDs.push(nodeID);
// Hide this placeholder link until it is certain we'll need it.
hide($placeholder);
return true;
}
else {
// Remove this placeholder link from the DOM because we won't need it.
remove($placeholder);
return false;
}
});
if ($placeholders.length === 0) {
return;
}
// Perform an AJAX request to retrieve node read timestamps.
Drupal.history.fetchTimestamps(nodeIDs, function () {
processNodeNewCommentLinks($placeholders);
});
}
};
function hide($placeholder) {
return $placeholder
// Find the parent <li>.
.closest('.comment-new-comments')
// Find the preceding <li>, if any, and give it the 'last' class.
.prev().addClass('last')
// Go back to the parent <li> and hide it.
.end().hide();
}
function remove($placeholder) {
hide($placeholder).remove();
}
function show($placeholder) {
return $placeholder
// Find the parent <li>.
.closest('.comment-new-comments')
// Find the preceding <li>, if any, and remove its 'last' class, if any.
.prev().removeClass('last')
// Go back to the parent <li> and show it.
.end().show();
}
function processNodeNewCommentLinks($placeholders) {
// Figure out which placeholders need the "x new comments" links.
var $placeholdersToUpdate = {};
var $placeholder;
$placeholders.each(function (index, placeholder) {
$placeholder = $(placeholder);
var timestamp = parseInt($placeholder.attr('data-history-node-last-comment-timestamp'), 10);
var nodeID = $placeholder.closest('[data-history-node-id]').attr('data-history-node-id');
var lastViewTimestamp = Drupal.history.getLastRead(nodeID);
// Queue this placeholder's "X new comments" link to be downloaded from the
// server.
if (timestamp > lastViewTimestamp) {
$placeholdersToUpdate[nodeID] = $placeholder;
}
// No "X new comments" link necessary; remove it from the DOM.
else {
remove($placeholder);
}
});
// Perform an AJAX request to retrieve node view timestamps.
var nodeIDs = Object.keys($placeholdersToUpdate);
if (nodeIDs.length === 0) {
return;
}
$.ajax({
url: Drupal.url('comments/render_new_comments_node_links'),
type: 'POST',
data: { 'node_ids[]' : nodeIDs },
dataType: 'json',
success: function (results) {
for (var nodeID in results) {
if (results.hasOwnProperty(nodeID) && $placeholdersToUpdate.hasOwnProperty(nodeID)) {
$placeholdersToUpdate[nodeID]
.attr('href', results[nodeID].first_new_comment_link)
.text(Drupal.formatPlural(results[nodeID].new_comment_count, '1 new comment', '@count new comments'))
.removeClass('hidden');
show($placeholdersToUpdate[nodeID]);
}
}
}
});
}
})(jQuery, Drupal);
<?php
/**
* @file
* Contains \Drupal\comment\CommentNewItem.
*/
namespace Drupal\comment;
use Drupal\Core\Entity\Plugin\DataType\IntegerItem;
/**
* The field item for the 'new' field.
*/
class CommentNewItem extends IntegerItem {
/**
* Definitions of the contained properties.
*
* @see self::getPropertyDefinitions()
*
* @var array
*/
static $propertyDefinitions;
/**
* Implements \Drupal\Core\TypedData\ComplexDataInterface::getPropertyDefinitions().
*/
public function getPropertyDefinitions() {
if (!isset(static::$propertyDefinitions)) {
static::$propertyDefinitions['value'] = array(
'type' => 'integer',
'label' => t('Integer value'),
'class' => '\Drupal\comment\CommentNewValue',
'computed' => TRUE,
);
}
return static::$propertyDefinitions;
}
}
<?php
/**
* @file
* Contains \Drupal\comment\CommentNewValue.
*/
namespace Drupal\comment;
use Drupal\Core\TypedData\TypedData;
use Drupal\Core\TypedData\ReadOnlyException;
use InvalidArgumentException;
/**
* A computed property for the integer value of the 'new' field.
*
* @todo: Declare the list of allowed values once supported.
*/
class CommentNewValue extends TypedData {
/**
* Implements \Drupal\Core\TypedData\TypedDataInterface::getValue().
*/
public function getValue() {
if (!isset($this->value)) {
if (!isset($this->parent)) {
throw new InvalidArgumentException('Computed properties require context for computation.');
}
$entity = $this->parent->getEntity();
$this->value = node_mark($entity->nid->target_id, $entity->changed->value);
}
return $this->value;
}
/**
* Implements \Drupal\Core\TypedData\TypedDataInterface::setValue().
*/
public function setValue($value, $notify = TRUE) {
if (isset($value)) {
throw new ReadOnlyException('Unable to set a computed property.');
}
}
}
......@@ -44,6 +44,8 @@ public function buildContent(array $entities, array $displays, $view_mode, $lang
}
$nodes = node_load_multiple($nids);
global $user;
foreach ($entities as $entity) {
if (isset($nodes[$entity->nid->target_id])) {
$node = $nodes[$entity->nid->target_id];
......@@ -66,6 +68,14 @@ public function buildContent(array $entities, array $displays, $view_mode, $lang
'#attributes' => array('class' => array('links', 'inline')),
);
}
if (!isset($entity->content['#attached'])) {
$entity->content['#attached'] = array();
}
$entity->content['#attached']['library'][] = array('comment', 'drupal.comment-by-viewer');
if (\Drupal::moduleHandler()->moduleExists('history') && $user->isAuthenticated()) {
$entity->content['#attached']['library'][] = array('comment', 'drupal.comment-new-indicator');
}
}
}
......@@ -79,11 +89,6 @@ protected function alterBuild(array &$build, EntityInterface $comment, EntityDis
$is_threaded = isset($comment->divs)
&& variable_get('comment_default_mode_' . $comment->bundle(), COMMENT_MODE_THREADED) == COMMENT_MODE_THREADED;
// Add 'new' anchor if needed.
if (!empty($comment->first_new)) {
$prefix .= "<a id=\"new\"></a>\n";
}
// Add indentation div or close open divs as needed.
if ($is_threaded) {
$build['#attached']['css'][] = drupal_get_path('module', 'comment') . '/css/comment.theme.css';
......
......@@ -13,7 +13,9 @@
use Drupal\Core\Controller\ControllerBase;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Node\NodeInterface;
use Drupal\Core\Session\AccountInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
......@@ -41,6 +43,13 @@ class CommentController extends ControllerBase implements ContainerInjectionInte
*/
protected $csrfToken;
/**
* The current user service.
*
* @var \Drupal\Core\Session\AccountInterface
*/
protected $currentUser;
/**
* Constructs a CommentController object.
*
......@@ -48,10 +57,13 @@ class CommentController extends ControllerBase implements ContainerInjectionInte
* HTTP kernel to handle requests.
* @param \Drupal\Core\Access\CsrfTokenGenerator $csrf_token
* The CSRF token manager service.
* @param \Drupal\Core\Session\AccountInterface $current_user
* The current user service.
*/
public function __construct(HttpKernelInterface $httpKernel, CsrfTokenGenerator $csrf_token) {
public function __construct(HttpKernelInterface $httpKernel, CsrfTokenGenerator $csrf_token, AccountInterface $current_user) {
$this->httpKernel = $httpKernel;
$this->csrfToken = $csrf_token;
$this->currentUser = $current_user;
}
/**
* {@inheritdoc}
......@@ -59,7 +71,8 @@ public function __construct(HttpKernelInterface $httpKernel, CsrfTokenGenerator
public static function create(ContainerInterface $container) {
return new static(
$container->get('http_kernel'),
$container->get('csrf_token')
$container->get('csrf_token'),
$container->get('current_user')
);
}
......@@ -223,4 +236,39 @@ public function getReplyForm(Request $request, NodeInterface $node, $pid = NULL)
return $build;
}
/**
* Returns a set of nodes' last read timestamps.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request of the page.
*
* @return Symfony\Component\HttpFoundation\JsonResponse
* The JSON response.
*/
public function renderNewCommentsNodeLinks(Request $request) {
if ($this->currentUser->isAnonymous()) {
throw new AccessDeniedHttpException();
}
$nids = $request->request->get('node_ids');
if (!isset($nids)) {
throw new NotFoundHttpException();
}
// Only handle up to 100 nodes.
$nids = array_slice($nids, 0, 100);
$links = array();
foreach ($nids as $nid) {
$node = node_load($nid);
$new = comment_num_new($node->id());
$query = comment_new_page_count($node->comment_count, $new, $node);
$links[$nid] = array(
'new_comment_count' => (int)$new,
'first_new_comment_link' => url('node/' . $node->id(), array('query' => $query, 'fragment' => 'new')),
);
}
return new JsonResponse($links);
}
}
......@@ -174,13 +174,6 @@ class Comment extends EntityNG implements CommentInterface {
*/
public $node_type;
/**
* The comment 'new' marker for the current user.
*
* @var \Drupal\Core\Entity\Field\FieldInterface
*/
public $new;
/**
* Initialize the object. Invoked upon construction and wake up.
*/
......@@ -202,7 +195,6 @@ protected function init() {
unset($this->status);
unset($this->thread);
unset($this->node_type);
unset($this->new);
}
/**
......@@ -451,13 +443,6 @@ public static function baseFieldDefinitions($entity_type) {
'type' => 'string_field',
'queryable' => FALSE,
);
$properties['new'] = array(
'label' => t('Comment new marker'),
'description' => t("The comment 'new' marker for the current user (0 read, 1 new, 2 updated)."),
'type' => 'integer_field',
'computed' => TRUE,
'class' => '\Drupal\comment\CommentNewItem',
);
return $properties;
}
......
......@@ -82,6 +82,11 @@ function testCommentClasses() {
}
// Request the node with the comment.
$this->drupalGet('node/' . $node->id());
$settings = $this->drupalGetSettings();
// Verify the data-history-node-id attribute, which is necessary for the
// by-viewer class and the "new" indicator, see below.
$this->assertIdentical(1, count($this->xpath('//*[@data-history-node-id="' . $node->id() . '"]')), 'data-history-node-id attribute is set on node.');
// Verify classes if the comment is visible for the current user.
if ($case['comment_status'] == COMMENT_PUBLISHED || $case['user'] == 'admin') {
......@@ -103,14 +108,12 @@ function testCommentClasses() {
$this->assertFalse(count($comments), 'by-node-author class not found.');
}
// Verify the by-viewer class.
$comments = $this->xpath('//*[contains(@class, "comment") and contains(@class, "by-viewer")]');
if ($case['comment_uid'] > 0 && $case['comment_uid'] == $case['user_uid']) {
$this->assertTrue(count($comments) == 1, 'by-viewer class found.');
}
else {
$this->assertFalse(count($comments), 'by-viewer class not found.');
}
// Verify the data-comment-user-id attribute, which is used by the
// drupal.comment-by-viewer library to add a by-viewer when the current
// user (the viewer) was the author of the comment. We do this in Java-
// Script to prevent breaking the render cache.
$this->assertIdentical(1, count($this->xpath('//*[contains(@class, "comment") and @data-comment-user-id="' . $case['comment_uid'] . '"]')), 'data-comment-user-id attribute is set on comment.');
$this->assertTrue(isset($settings['ajaxPageState']['js']['core/modules/comment/js/comment-by-viewer.js']), 'drupal.comment-by-viewer library is present.');
}
// Verify the unpublished class.
......@@ -122,20 +125,14 @@ function testCommentClasses() {
$this->assertFalse(count($comments), 'unpublished class not found.');
}
// Verify the new class.
// Verify the data-comment-timestamp attribute, which is used by the
// drupal.comment-new-indicator library to add a "new" indicator to each
// comment that was created or changed after the last time the current
// user read the corresponding node.
if ($case['comment_status'] == COMMENT_PUBLISHED || $case['user'] == 'admin') {
$comments = $this->xpath('//*[contains(@class, "comment") and contains(@class, "new")]');
if ($case['user'] != 'anonymous') {
$this->assertTrue(count($comments) == 1, 'new class found.');
// Request the node again. The new class should disappear.
$this->drupalGet('node/' . $node->id());
$comments = $this->xpath('//*[contains(@class, "comment") and contains(@class, "new")]');
$this->assertFalse(count($comments), 'new class not found.');
}
else {
$this->assertFalse(count($comments), 'new class not found.');
}
$this->assertIdentical(1, count($this->xpath('//*[contains(@class, "comment")]/*[@data-comment-timestamp="' . $comment->changed->value . '"]')), 'data-comment-timestamp attribute is set on comment');
$expectedJS = ($case['user'] !== 'anonymous');
$this->assertIdentical($expectedJS, isset($settings['ajaxPageState']['js'][