From 8c8bf7566d8e375cf7cacfad9cfa063663800799 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Mon, 20 Jul 2015 14:46:23 +0100
Subject: [PATCH] Issue #2082315 by Wim Leers, realityloop, mrjmd: Tracker
 history markers ("new" and "updated" markers, "x new replies" links) forces
 render caching to be per user

---
 core/modules/tracker/js/tracker-history.js    | 122 ++++++++++++++++
 .../modules/tracker/src/Tests/TrackerTest.php | 138 ++++++++----------
 core/modules/tracker/tracker.libraries.yml    |   8 +
 core/modules/tracker/tracker.pages.inc        |  32 ++--
 4 files changed, 207 insertions(+), 93 deletions(-)
 create mode 100644 core/modules/tracker/js/tracker-history.js
 create mode 100644 core/modules/tracker/tracker.libraries.yml

diff --git a/core/modules/tracker/js/tracker-history.js b/core/modules/tracker/js/tracker-history.js
new file mode 100644
index 000000000000..fa6af55b8497
--- /dev/null
+++ b/core/modules/tracker/js/tracker-history.js
@@ -0,0 +1,122 @@
+/**
+ * Attaches behaviors for the Tracker module's History module integration.
+ *
+ * May only be loaded for authenticated users, with the History module enabled.
+ */
+(function ($, Drupal, window) {
+
+  "use strict";
+
+  /**
+   * Render "new" and "updated" node indicators, as well as "X new" replies links.
+   */
+  Drupal.behaviors.trackerHistory = {
+    attach: function (context) {
+      // Find all "new" comment indicator placeholders newer than 30 days ago that
+      // have not already been read after their last comment timestamp.
+      var nodeIDs = [];
+      var $nodeNewPlaceholders = $(context)
+        .find('[data-history-node-timestamp]')
+        .once('history')
+        .filter(function () {
+          var nodeTimestamp = parseInt(this.getAttribute('data-history-node-timestamp'), 10);
+          var nodeID = this.getAttribute('data-history-node-id');
+          if (Drupal.history.needsServerCheck(nodeID, nodeTimestamp)) {
+            nodeIDs.push(nodeID);
+            return true;
+          }
+          else {
+            return false;
+          }
+        });
+
+      // Find all "new" comment indicator placeholders newer than 30 days ago that
+      // have not already been read after their last comment timestamp.
+      var $newRepliesPlaceholders = $(context)
+        .find('[data-history-node-last-comment-timestamp]')
+        .once('history')
+        .filter(function () {
+          var lastCommentTimestamp = parseInt(this.getAttribute('data-history-node-last-comment-timestamp'), 10);
+          var nodeTimestamp = parseInt(this.previousSibling.previousSibling.getAttribute('data-history-node-timestamp'), 10);
+          // Discard placeholders that have zero comments.
+          if (lastCommentTimestamp === nodeTimestamp) {
+            return false;
+          }
+          var nodeID = this.previousSibling.previousSibling.getAttribute('data-history-node-id');
+          if (Drupal.history.needsServerCheck(nodeID, lastCommentTimestamp)) {
+            if (nodeIDs.indexOf(nodeID) === -1) {
+              nodeIDs.push(nodeID);
+            }
+            return true;
+          }
+          else {
+            return false;
+          }
+        });
+
+      if ($nodeNewPlaceholders.length === 0 && $newRepliesPlaceholders.length === 0) {
+        return;
+      }
+
+      // Fetch the node read timestamps from the server.
+      Drupal.history.fetchTimestamps(nodeIDs, function () {
+        processNodeNewIndicators($nodeNewPlaceholders);
+        processNewRepliesIndicators($newRepliesPlaceholders);
+      });
+    }
+  };
+
+  function processNodeNewIndicators($placeholders) {
+    var newNodeString = Drupal.t('new');
+    var updatedNodeString = Drupal.t('updated');
+
+    $placeholders.each(function (index, placeholder) {
+      var timestamp = parseInt(placeholder.getAttribute('data-history-node-timestamp'), 10);
+      var nodeID = placeholder.getAttribute('data-history-node-id');
+      var lastViewTimestamp = Drupal.history.getLastRead(nodeID);
+
+      if (timestamp > lastViewTimestamp) {
+        var message = (lastViewTimestamp === 0) ? newNodeString : updatedNodeString;
+        $(placeholder).append('<span class="marker">' + message + '</span>');
+      }
+    });
+  }
+
+  function processNewRepliesIndicators($placeholders) {
+    // Figure out which placeholders need the "x new" replies links.
+    var placeholdersToUpdate = {};
+    $placeholders.each(function (index, placeholder) {
+      var timestamp = parseInt(placeholder.getAttribute('data-history-node-last-comment-timestamp'), 10);
+      var nodeID = placeholder.previousSibling.previousSibling.getAttribute('data-history-node-id');
+      var lastViewTimestamp = Drupal.history.getLastRead(nodeID);
+
+      // Queue this placeholder's "X new" replies link to be downloaded from the
+      // server.
+      if (timestamp > lastViewTimestamp) {
+        placeholdersToUpdate[nodeID] = 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)) {
+            var url = results[nodeID].first_new_comment_link;
+            var text = Drupal.formatPlural(results[nodeID].new_comment_count, '1 new', '@count new');
+            $(placeholdersToUpdate[nodeID]).append('<br /><a href="' + url + '">' + text + '</a>');
+          }
+        }
+      }
+    });
+  }
+
+})(jQuery, Drupal, window);
diff --git a/core/modules/tracker/src/Tests/TrackerTest.php b/core/modules/tracker/src/Tests/TrackerTest.php
index 3cb4186e705c..c57f1c98dee3 100644
--- a/core/modules/tracker/src/Tests/TrackerTest.php
+++ b/core/modules/tracker/src/Tests/TrackerTest.php
@@ -10,7 +10,9 @@
 use Drupal\comment\CommentInterface;
 use Drupal\comment\Tests\CommentTestTrait;
 use Drupal\Core\Cache\Cache;
+use Drupal\Core\Session\AccountInterface;
 use Drupal\field\Entity\FieldStorageConfig;
+use Drupal\node\Entity\Node;
 use Drupal\simpletest\WebTestBase;
 use Drupal\system\Tests\Cache\AssertPageCacheContextsAndTagsTrait;
 
@@ -54,6 +56,10 @@ protected function setUp() {
     $this->user = $this->drupalCreateUser($permissions);
     $this->otherUser = $this->drupalCreateUser($permissions);
     $this->addDefaultCommentField('node', 'page');
+    user_role_grant_permissions(AccountInterface::ANONYMOUS_ROLE, array(
+      'access content',
+      'access user profiles',
+    ));
   }
 
   /**
@@ -169,97 +175,54 @@ function testTrackerUser() {
   }
 
   /**
-   * Tests for the presence of the "new" flag for nodes.
+   * Tests the metadata for the "new"/"updated" indicators.
    */
-  function testTrackerNewNodes() {
+  function testTrackerHistoryMetadata() {
     $this->drupalLogin($this->user);
 
+    // Create a page node.
     $edit = array(
       'title' => $this->randomMachineName(8),
     );
-
     $node = $this->drupalCreateNode($edit);
-    $title = $edit['title'];
-    $this->drupalGet('activity');
-    $this->assertPattern('/' . $title . '.*new/', 'New nodes are flagged as such in the activity listing.');
-
-    $this->drupalGet('node/' . $node->id());
-    // Simulate the JavaScript on the node page to mark the node as read.
-    // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037
-    //   lands.
-    $this->curlExec(array(
-      CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)),
-      CURLOPT_HTTPHEADER => array(
-        'Accept: application/json',
-      ),
-    ));
-    $this->drupalGet('activity');
-    $this->assertNoPattern('/' . $title . '.*new/', 'Visited nodes are not flagged as new.');
 
-    $this->drupalLogin($this->otherUser);
+    // Verify that the history metadata is present.
     $this->drupalGet('activity');
-    $this->assertPattern('/' . $title . '.*new/', 'For another user, new nodes are flagged as such in the tracker listing.');
-
-    $this->drupalGet('node/' . $node->id());
-    // Simulate the JavaScript on the node page to mark the node as read.
-    // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037
-    //   lands.
-    $this->curlExec(array(
-      CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)),
-      CURLOPT_HTTPHEADER => array(
-        'Accept: application/json',
-      ),
-    ));
-    $this->drupalGet('activity');
-    $this->assertNoPattern('/' . $title . '.*new/', 'For another user, visited nodes are not flagged as new.');
-  }
-
-  /**
-   * Tests for comment counters on the tracker listing.
-   */
-  function testTrackerNewComments() {
-    $this->drupalLogin($this->user);
-
-    $node = $this->drupalCreateNode(array(
-      'title' => $this->randomMachineName(8),
-    ));
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime());
+    $this->drupalGet('activity/' . $this->user->id());
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime());
+    $this->drupalGet('user/' . $this->user->id() . '/activity');
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->getChangedTime());
 
-    // Add a comment to the page.
+    // Add a comment to the page, make sure it is created after the node by
+    // sleeping for one second, to ensure the last comment timestamp is
+    // different from before.
     $comment = array(
       'subject[0][value]' => $this->randomMachineName(),
       'comment_body[0][value]' => $this->randomMachineName(20),
     );
+    sleep(1);
     $this->drupalPostForm('comment/reply/node/' . $node->id() . '/comment', $comment, t('Save'));
-    // The new comment is automatically viewed by the current user. Simulate the
-    // JavaScript that does this.
-    // @todo Get rid of curlExec() once https://www.drupal.org/node/2074037
-    //   lands.
-    $this->curlExec(array(
-      CURLOPT_URL => \Drupal::url('history.read_node', ['node' => $node->id()], array('absolute' => TRUE)),
-      CURLOPT_HTTPHEADER => array(
-        'Accept: application/json',
-      ),
-    ));
+    // Reload the node so that comment.module's hook_node_load()
+    // implementation can set $node->last_comment_timestamp for the freshly
+    // posted comment.
+    $node = Node::load($node->id());
 
-    $this->drupalLogin($this->otherUser);
+    // Verify that the history metadata is updated.
     $this->drupalGet('activity');
-    $this->assertText('1 new', 'New comments are counted on the tracker listing pages.');
-    $this->drupalGet('node/' . $node->id());
-
-    // Add another comment as otherUser.
-    $comment = array(
-      'subject[0][value]' => $this->randomMachineName(),
-      'comment_body[0][value]' => $this->randomMachineName(20),
-    );
-    // If the comment is posted in the same second as the last one then Drupal
-    // can't tell the difference, so we wait one second here.
-    sleep(1);
-    $this->drupalPostForm('comment/reply/node/' . $node->id(). '/comment', $comment, t('Save'));
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp);
+    $this->drupalGet('activity/' . $this->user->id());
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp);
+    $this->drupalGet('user/' . $this->user->id() . '/activity');
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp);
 
-    $this->drupalLogin($this->user);
+    // Log out, now verify that the metadata is still there, but the library is
+    // not.
+    $this->drupalLogout();
     $this->drupalGet('activity');
-    $this->assertText('1 new', 'New comments are counted on the tracker listing pages.');
-    $this->assertLink(t('1 new'));
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE);
+    $this->drupalGet('user/' . $this->user->id() . '/activity');
+    $this->assertHistoryMetadata($node->id(), $node->getChangedTime(), $node->get('comment')->last_comment_timestamp, FALSE);
   }
 
   /**
@@ -371,8 +334,6 @@ function testTrackerCronIndexing() {
     foreach ($nodes as $i => $node) {
       $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', array('@i' => $i)));
     }
-    $this->assertText('1 new', 'One new comment is counted on the tracker listing pages.');
-    $this->assertText('updated', 'Node is listed as updated');
 
     // Fetch the site-wide tracker.
     $this->drupalGet('activity');
@@ -381,7 +342,6 @@ function testTrackerCronIndexing() {
     foreach ($nodes as $i => $node) {
       $this->assertText($node->label(), format_string('Node @i is displayed on the tracker listing pages.', array('@i' => $i)));
     }
-    $this->assertText('1 new', 'New comment is counted on the tracker listing pages.');
   }
 
   /**
@@ -411,4 +371,32 @@ function testTrackerAdminUnpublish() {
     $this->drupalGet('activity');
     $this->assertText(t('No content available.'), 'A node is displayed on the tracker listing pages.');
   }
+
+  /**
+   * Passes if the appropriate history metadata exists.
+   *
+   * Verify the data-history-node-id, data-history-node-timestamp and
+   * data-history-node-last-comment-timestamp attributes, which are used by the
+   * drupal.tracker-history library to add the appropriate "new" and "updated"
+   * indicators, as well as the "x new" replies link to the tracker.
+   * We do this in JavaScript to prevent breaking the render cache.
+   *
+   * @param $node_id
+   *   A node ID, that must exist as a data-history-node-id attribute
+   * @param $node_timestamp
+   *   A node timestamp, that must exist as a data-history-node-timestamp
+   *   attribute.
+   * @param $node_last_comment_timestamp
+   *   A node's last comment timestamp, that must exist as a
+   *   data-history-node-last-comment-timestamp attribute.
+   * @param bool $library_is_present
+   *   Whether the drupal.tracker-history library should be present or not.
+   */
+  function assertHistoryMetadata($node_id, $node_timestamp, $node_last_comment_timestamp, $library_is_present = TRUE) {
+    $settings = $this->getDrupalSettings();
+    $this->assertIdentical($library_is_present, isset($settings['ajaxPageState']) && in_array('tracker/history', explode(',', $settings['ajaxPageState']['libraries'])), 'drupal.tracker-history library is present.');
+    $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-id="' . $node_id . '" and @data-history-node-timestamp="' . $node_timestamp . '"]')), 'Tracker table cell contains the data-history-node-id and data-history-node-timestamp attributes for the node.');
+    $this->assertIdentical(1, count($this->xpath('//table/tbody/tr/td[@data-history-node-last-comment-timestamp="' . $node_last_comment_timestamp . '"]')), 'Tracker table cell contains the data-history-node-last-comment-timestamp attribute for the node.');
+  }
+
 }
diff --git a/core/modules/tracker/tracker.libraries.yml b/core/modules/tracker/tracker.libraries.yml
new file mode 100644
index 000000000000..11c103c99851
--- /dev/null
+++ b/core/modules/tracker/tracker.libraries.yml
@@ -0,0 +1,8 @@
+history:
+  version: VERSION
+  js:
+    js/tracker-history.js: {}
+  dependencies:
+    - core/jquery
+    - core/drupal
+    - history/api
diff --git a/core/modules/tracker/tracker.pages.inc b/core/modules/tracker/tracker.pages.inc
index 84a33664aa64..3be396684982 100644
--- a/core/modules/tracker/tracker.pages.inc
+++ b/core/modules/tracker/tracker.pages.inc
@@ -63,6 +63,13 @@ function tracker_page($account = NULL) {
       else {
         $nodes[$nid]->comment_count += $statistics->comment_count;
       }
+      // Make the last comment timestamp reflect the latest comment.
+      if (!isset($nodes[$nid]->last_comment_timestamp)) {
+        $nodes[$nid]->last_comment_timestamp = $statistics->last_comment_timestamp;
+      }
+      else {
+        $nodes[$nid]->last_comment_timestamp = max($nodes[$nid]->last_comment_timestamp, $statistics->last_comment_timestamp);
+      }
     }
 
     // Display the data.
@@ -75,25 +82,8 @@ function tracker_page($account = NULL) {
       $comments = 0;
       if ($node->comment_count) {
         $comments = $node->comment_count;
-
-        if ($new = \Drupal::service('comment.manager')->getCountNewComments($node)) {
-          $comments = array(
-            '#type' => 'link',
-            '#url' => $node->urlInfo(),
-            '#title' => \Drupal::translation()->formatPlural($new, '1 new', '@count new'),
-            '#options' => array(
-              'fragment' => 'new',
-            ),
-            '#prefix' => $node->comment_count . '<br />',
-          );
-        }
       }
 
-      $mark_build = array(
-        '#theme' => 'mark',
-        '#status' => node_mark($node->id(), $node->getChangedTime()),
-      );
-
       $row = array(
         'type' => SafeMarkup::checkPlain(node_get_type_label($node)),
         'title' => array(
@@ -101,8 +91,9 @@ function tracker_page($account = NULL) {
             '#type' => 'link',
             '#url' => $node->urlInfo(),
             '#title' => $node->getTitle(),
-            '#suffix' => ' ' . drupal_render($mark_build),
           ),
+          'data-history-node-id' => $node->id(),
+          'data-history-node-timestamp' => $node->getChangedTime(),
         ),
         'author' => array(
           'data' => array(
@@ -113,6 +104,7 @@ function tracker_page($account = NULL) {
         'comments' => array(
           'class' => array('comments'),
           'data' => $comments,
+          'data-history-node-last-comment-timestamp' => $node->last_comment_timestamp,
         ),
         'last updated' => array(
           'data' => t('!time ago', array(
@@ -148,5 +140,9 @@ function tracker_page($account = NULL) {
   $page['#cache']['tags'] = $cache_tags;
   $page['#cache']['contexts'][] = 'user.node_grants:view';
 
+  if (Drupal::moduleHandler()->moduleExists('history') && \Drupal::currentUser()->isAuthenticated()) {
+    $page['#attached']['library'][] = 'tracker/history';
+  }
+
   return $page;
 }
-- 
GitLab