Skip to content
Snippets Groups Projects
Commit 60ba036f authored by catch's avatar catch
Browse files

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
No related branches found
No related tags found
No related merge requests found
Showing
with 814 additions and 178 deletions
......@@ -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']['core/modules/comment/js/comment-new-indicator.js']), 'drupal.comment-new-indicator library is present.');
}
}
}
......
......@@ -154,10 +154,6 @@ function setEnvironment(array $info) {
));
$comment->save();
$this->comment = $comment;
// comment_num_new() relies on history_read(), so ensure that no one has
// seen the node of this comment.
db_delete('history')->condition('nid', $this->node->id())->execute();
}
else {
$cids = db_query("SELECT cid FROM {comment}")->fetchCol();
......
......@@ -31,6 +31,43 @@ public static function getInfo() {
);
}
/**
* Get node "x new comments" metadata from the server for the current user.
*
* @param array $node_ids
* An array of node IDs.
*
* @return string
* The response body.
*/
protected function renderNewCommentsNodeLinks(array $node_ids) {
// Build POST values.
$post = array();
for ($i = 0; $i < count($node_ids); $i++) {
$post['node_ids[' . $i . ']'] = $node_ids[$i];
}
// Serialize POST values.
foreach ($post as $key => $value) {
// Encode according to application/x-www-form-urlencoded
// Both names and values needs to be urlencoded, according to
// http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1
$post[$key] = urlencode($key) . '=' . urlencode($value);
}
$post = implode('&', $post);
// Perform HTTP request.
return $this->curlExec(array(
CURLOPT_URL => url('comments/render_new_comments_node_links', array('absolute' => TRUE)),
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $post,
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
),
));
}
/**
* Tests new comment marker.
*/
......@@ -41,8 +78,12 @@ public function testCommentNewCommentsIndicator() {
$this->node = $this->drupalCreateNode(array('type' => 'article', 'promote' => 1, 'comment' => COMMENT_NODE_OPEN));
$this->drupalGet('node');
$this->assertNoLink(t('@count comments', array('@count' => 0)));
$this->assertNoLink(t('@count new comments', array('@count' => 0)));
$this->assertLink(t('Read more'));
// Verify the data-history-node-last-comment-timestamp attribute, which is
// used by the drupal.node-new-comments-link library to determine whether
// a "x new comments" link might be necessary or not. We do this in
// JavaScript to prevent breaking the render cache.
$this->assertIdentical(0, count($this->xpath('//*[@data-history-node-last-comment-timestamp]')), 'data-history-node-last-comment-timestamp attribute is not set.');
// Create a new comment. This helper function may be run with different
// comment settings so use $comment->save() to avoid complex setup.
......@@ -64,17 +105,30 @@ public function testCommentNewCommentsIndicator() {
// Log in with 'web user' and check comment links.
$this->drupalLogin($this->web_user);
$this->drupalGet('node');
$this->assertLink(t('1 new comment'));
$this->clickLink(t('1 new comment'));
$this->assertRaw('<a id="new"></a>', 'Found "new" marker.');
$this->assertTrue($this->xpath('//a[@id=:new]/following-sibling::a[1][@id=:comment_id]', array(':new' => 'new', ':comment_id' => 'comment-1')), 'The "new" anchor is positioned at the right comment.');
// Verify the data-history-node-last-comment-timestamp attribute. Given its
// value, the drupal.node-new-comments-link library would determine that the
// node received a comment after the user last viewed it, and hence it would
// perform an HTTP request to render the "new comments" node link.
$this->assertIdentical(1, count($this->xpath('//*[@data-history-node-last-comment-timestamp="' . $comment->changed->value . '"]')), 'data-history-node-last-comment-timestamp attribute is set to the correct value.');
$response = $this->renderNewCommentsNodeLinks(array($this->node->id()));
$this->assertResponse(200);
$json = drupal_json_decode($response);
$expected = array($this->node->id() => array(
'new_comment_count' => 1,
'first_new_comment_link' => url('node/' . $this->node->id(), array('fragment' => 'new')),
));
$this->assertIdentical($expected, $json);
// Test if "new comment" link is correctly removed.
$this->drupalGet('node');
$this->assertLink(t('1 comment'));
$this->assertLink(t('Read more'));
$this->assertNoLink(t('1 new comment'));
$this->assertNoLink(t('@count new comments', array('@count' => 0)));
// Failing to specify node IDs for the endpoint should return a 404.
$this->renderNewCommentsNodeLinks(array());
$this->assertResponse(404);
// Accessing the endpoint as the anonymous user should return a 403.
$this->drupalLogout();
$this->renderNewCommentsNodeLinks(array($this->node->id()));
$this->assertResponse(403);
$this->renderNewCommentsNodeLinks(array());
$this->assertResponse(403);
}
}
......@@ -15,7 +15,6 @@
* - changed: Formatted date and time for when the comment was last changed.
* Preprocess functions can reformat it by calling format_date() with the
* desired parameters on the 'comment.changed' variable.
* - new: New comment marker.
* - permalink: Comment permalink.
* - submitted: Submission information created from author and created
* during template_preprocess_comment().
......@@ -32,8 +31,6 @@
* - preview: When previewing a new or edited comment.
* The following applies only to viewers who are registered users:
* - unpublished: An unpublished comment visible only to administrators.
* - by-viewer: Comment by the user currently viewing the page.
* - new: New comment since the last visit.
* - title_prefix: Additional output populated by modules, intended to be
* displayed in front of the main title tag that appears in the template.
* - title_suffix: Additional output populated by modules, intended to be
......@@ -66,9 +63,12 @@
<article{{ attributes }}>
{{ title_prefix }}
{% if new %}
<mark class="new">{{ new }}</mark>
{% endif %}
{#
Hide the "new" indicator by default, let a piece of JavaScript ask
the server which comments are new for the user. Rendering the final
"new" indicator here would break the render cache.
#}
<mark class="hidden new" data-comment-timestamp="{{ new_indicator_timestamp }}"></mark>
<h3{{ title_attributes }}>{{ title }}</h3>
......
......@@ -106,3 +106,24 @@ function history_user_delete($account) {
->condition('uid', $account->id())
->execute();
}
/**
* Implements hook_library_info().
*/
function history_library_info() {
$libraries['drupal.history'] = array(
'title' => 'History',
'version' => Drupal::VERSION,
'js' => array(
drupal_get_path('module', 'history') . '/js/history.js' => array(),
),
'dependencies' => array(
array('system', 'jquery'),
array('system', 'drupalSettings'),
array('system', 'drupal'),
array('system', 'drupal.ajax'),
),
);
return $libraries;
}
history_get_last_node_view:
pattern: '/history/get_node_read_timestamps'
defaults:
_controller: '\Drupal\history\Controller\HistoryController::getNodeReadTimestamps'
requirements:
_permission: 'access content'
history_read_node:
pattern: '/history/{node}/read'
defaults:
_controller: '\Drupal\history\Controller\HistoryController::readNode'
requirements:
_entity_access: 'node.view'
/**
* JavaScript API for the History module, with client-side caching.
*
* May only be loaded for authenticated users, with the History module enabled.
*/
(function ($, Drupal, drupalSettings, storage) {
"use strict";
var currentUserID = parseInt(drupalSettings.user.uid, 10);
// Any comment that is older than 30 days is automatically considered read,
// so for these we don't need to perform a request at all!
var thirtyDaysAgo = Math.round(new Date().getTime() / 1000) - 30 * 24 * 60 * 60;
Drupal.history = {
/**
* Fetch "last read" timestamps for the given nodes.
*
* @param Array nodeIDs
* An array of node IDs.
* @param Function callback
* A callback that is called after the requested timestamps were fetched.
*/
fetchTimestamps: function (nodeIDs, callback) {
$.ajax({
url: Drupal.url('history/get_node_read_timestamps'),
type: 'POST',
data: { 'node_ids[]' : nodeIDs },
dataType: 'json',
success: function (results) {
for (var nodeID in results) {
if (results.hasOwnProperty(nodeID)) {
storage.setItem('Drupal.history.' + currentUserID + '.' + nodeID, results[nodeID]);
}
}
callback();
}
});
},
/**
* Get the last read timestamp for the given node.
*
* @param Number|String nodeID
* A node ID.
*
* @return Number
* A UNIX timestamp.
*/
getLastRead: function (nodeID) {
return parseInt(storage.getItem('Drupal.history.' + currentUserID + '.' + nodeID) || 0, 10);
},
/**
* Marks a node as read, store the last read timestamp in client-side storage.
*
* @param Number|String nodeID
* A node ID.
*/
markAsRead: function (nodeID) {
$.ajax({
url: Drupal.url('history/' + nodeID + '/read'),
type: 'POST',
dataType: 'json',
success: function (timestamp) {
storage.setItem('Drupal.history.' + currentUserID + '.' + nodeID, timestamp);
}
});
},
/**
* Determines whether a server check is necessary.
*
* Any content that is >30 days old never gets a "new" or "updated" indicator.
* Any content that was published before the oldest known reading also never
* gets a "new" or "updated" indicator, because it must've been read already.
*
* @param Number|String nodeID
* A node ID.
* @param Number contentTimestamp
* The time at which some content (e.g. a comment) was published.
*
* @return Boolean
* Whether a server check is necessary for the given node and its timestamp.
*/
needsServerCheck: function (nodeID, contentTimestamp) {
// First check if the content is older than 30 days, then we can bail early.
if (contentTimestamp < thirtyDaysAgo) {
return false;
}
var minLastReadTimestamp = parseInt(storage.getItem('Drupal.history.' + currentUserID + '.' + nodeID) || 0, 10);
return contentTimestamp > minLastReadTimestamp;
}
};
})(jQuery, Drupal, drupalSettings, window.localStorage);
<?php
/**
* @file
* Contains \Drupal\comment\Controller\HistoryController.
*/
namespace Drupal\history\Controller;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Drupal\Core\Controller\ControllerBase;
use Drupal\node\NodeInterface;
/**
* Returns responses for History module routes.
*/
class HistoryController extends ControllerBase {
/**
* 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 getNodeReadTimestamps(Request $request) {
if ($this->currentUser()->isAnonymous()) {
throw new AccessDeniedHttpException();
}
$nids = $request->request->get('node_ids');
if (!isset($nids)) {
throw new NotFoundHttpException();
}
$timestamps = array();
foreach ($nids as $nid) {
$timestamps[$nid] = (int) history_read($nid);
}
return new JsonResponse($timestamps);
}
/**
* Marks a node as read by the current user right now.
*
* @param \Symfony\Component\HttpFoundation\Request $request
* The request of the page.
* @param \Drupal\node\NodeInterface $node
* The node whose "last read" timestamp should be updated.
*/
public function readNode(Request $request, NodeInterface $node) {
if ($this->currentUser()->isAnonymous()) {
throw new AccessDeniedHttpException();
}
// Update the history table, stating that this user viewed this node.
history_write($node->id());
return new JsonResponse((int)history_read($node->id()));
}
}
<?php
/**
* @file
* Contains \Drupal\history\Tests\HistoryTest.
*/
namespace Drupal\history\Tests;
use Drupal\simpletest\WebTestBase;
/**
* Tests the History endpoints.
*/
class HistoryTest extends WebTestBase {
/**
* Modules to enable.
*
* @var array
*/
public static $modules = array('node', 'history');
/**
* The main user for testing.
*
* @var objec
*/
protected $user;
/**
* A page node for which to check content statistics.
*
* @var object
*/
protected $test_node;
public static function getInfo() {
return array(
'name' => 'History endpoints',
'description' => 'Tests the History endpoints',
'group' => 'History'
);
}
function setUp() {
parent::setUp();
$this->drupalCreateContentType(array('type' => 'page', 'name' => 'Basic page'));
$this->user = $this->drupalCreateUser(array('create page content', 'access content'));
$this->drupalLogin($this->user);
$this->test_node = $this->drupalCreateNode(array('type' => 'page', 'uid' => $this->user->id()));
}
/**
* Get node read timestamps from the server for the current user.
*
* @param array $node_ids
* An array of node IDs.
*
* @return string
* The response body.
*/
protected function getNodeReadTimestamps(array $node_ids) {
// Build POST values.
$post = array();
for ($i = 0; $i < count($node_ids); $i++) {
$post['node_ids[' . $i . ']'] = $node_ids[$i];
}
// Serialize POST values.
foreach ($post as $key => $value) {
// Encode according to application/x-www-form-urlencoded
// Both names and values needs to be urlencoded, according to
// http://www.w3.org/TR/html4/interact/forms.html#h-17.13.4.1
$post[$key] = urlencode($key) . '=' . urlencode($value);
}
$post = implode('&', $post);
// Perform HTTP request.
return $this->curlExec(array(
CURLOPT_URL => url('history/get_node_read_timestamps', array('absolute' => TRUE)),
CURLOPT_POST => TRUE,
CURLOPT_POSTFIELDS => $post,
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
'Content-Type: application/x-www-form-urlencoded',
),
));
}
/**
* Mark a node as read for the current user.
*
* @param int $node_id
* A node ID.
*
* @return string
* The response body.
*/
protected function markNodeAsRead($node_id) {
return $this->curlExec(array(
CURLOPT_URL => url('history/' . $node_id . '/read', array('absolute' => TRUE)),
CURLOPT_HTTPHEADER => array(
'Accept: application/json',
),
));
}
/**
* Verifies that the history endpoints work.
*/
function testHistory() {
$nid = $this->test_node->id();
// Retrieve "last read" timestamp for test node, for the current user.
$response = $this->getNodeReadTimestamps(array($nid));
$this->assertResponse(200);
$json = drupal_json_decode($response);
$this->assertIdentical(array(1 => 0), $json, 'The node has not yet been read.');
// View the node.
$this->drupalGet('node/' . $nid);
// JavaScript present to record the node read.
$settings = $this->drupalGetSettings();
$this->assertTrue(isset($settings['ajaxPageState']['js']['core/modules/history/js/history.js']), 'drupal.history library is present.');
$this->assertRaw('Drupal.history.markAsRead(' . $nid . ')', 'History module JavaScript API call to mark node as read present on page.');
// Simulate JavaScript: perform HTTP request to mark node as read.
$response = $this->markNodeAsRead($nid);
$this->assertResponse(200);
$timestamp = drupal_json_decode($response);
$this->assertTrue(is_numeric($timestamp), 'Node has been marked as read. Timestamp received.');
// Retrieve "last read" timestamp for test node, for the current user.
$response = $this->getNodeReadTimestamps(array($nid));
$this->assertResponse(200);
$json = drupal_json_decode($response);
$this->assertIdentical(array(1 => $timestamp), $json, 'The node has been read.');
// Failing to specify node IDs for the first endpoint should return a 404.
$this->getNodeReadTimestamps(array());
$this->assertResponse(404);
// Accessing either endpoint as the anonymous user should return a 403.
$this->drupalLogout();
$this->getNodeReadTimestamps(array($nid));
$this->assertResponse(403);
$this->getNodeReadTimestamps(array());
$this->assertResponse(403);
$this->markNodeAsRead($nid);
$this->assertResponse(403);
}
}
......@@ -582,14 +582,28 @@ function node_show(EntityInterface $node, $message = FALSE) {
}
// For markup consistency with other pages, use node_view_multiple() rather than node_view().
$nodes = array('nodes' => node_view_multiple(array($node->id() => $node), 'full'));
$page = array('nodes' => node_view_multiple(array($node->id() => $node), 'full'));
// Update the history table, stating that this user viewed this node.
if (module_exists('history')) {
history_write($node->id());
global $user;
if (Drupal::moduleHandler()->moduleExists('history') && $user->isAuthenticated()) {
$page['#attached'] = array(
'js' => array(
// When the window's "load" event is triggered, mark the node as read.
// This still allows for Drupal behaviors (which are triggered on the
// "DOMContentReady" event) to add "new" and "updated" indicators.
array(
'data' => 'window.addEventListener("load",function(){Drupal.history.markAsRead(' . $node->id() . ');},false);',
'type' => 'inline',
),
),
'library' => array(
array('history', 'drupal.history'),
)
);
}
return $nodes;
return $page;
}
/**
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment