Using "new" indicator in a custom controller

Submitted by victor.bourgade on Tue, 06/15/2021 - 20:05

In case you are not using the default view display provided by Drupal core you will probably lose the "new" indicator feature, which append a small badge on new comments and nodes the user haven't seen yet.

Comment with the indicator

Though, implementing this in a custom controller is just about attaching the right libraries and passing to the JS code the good HTML attributes.

In my case, I was using comments as messages and have created a custom controller to fake an "inbox" that display comments, the same way an email inbox would. I wanted the user to be able to quickly see which message (comment) was new and which one were the old one.

In my controller I just attached the two libraries needed:

/**
   * Display content.
   *
   * @return array
   *   Return controller response.
   */
  public function content() {
    $build['#attached'] = [
      'library' => [
        'comment/drupal.comment-new-indicator',
        'history/mark-as-read'
    ];
   // Your custom code here.
}
  • comment/drupal.comment-new-indicator is used to display the "new" indicator and needs some attributes to do so.
  • history/mark-ad-read is used to log in the history that the user has now seen the comment.

Once the libraries are added, what you will have to do is send the timestamp of when the comment was created to the twig template and making an array of node ids to be marked as read:

    /** @var \Drupal\node\Entity\Node $node */
    foreach ($nodes as $node) {
      /** @var Drupal\comment\CommentStorage $comment_storage */
      $comment_storage = \Drupal::entityTypeManager()->getStorage('comment');
      // Get the comments from the node.
      $comments = $comment_storage->loadThread($node, 'comment', CommentManagerInterface::COMMENT_MODE_FLAT);
      foreach ($comments as $comment) {
        // Creates a variable that is passed to twig with all the informations we need to create our inbox.
        $messages[$node->id()]['content'][] = [
          'from' => $comment->getOwner()->getAccountname(),
          'text' => Markup::create($comment->get('comment_body')->getValue()[0]['value']),
          'date' => CoreAgrieasyUtility::formatDate($comment->getCreatedTime()),
          'timestamp' => $comment->getCreatedTime(),
        ];
      }
      // Add nodes to be marked as read.
      // It is needed to pass the array of node ids to drupalSettings.history.nodesToMarkAsRead,
      // as it is in this global variable that the javascript from history module will retreive what was read.
      $build['#attached']['drupalSettings']['history']['nodesToMarkAsRead'][$node->id()] = $node->id();
    }

Now let's summarize all of this and send our controller to a custom template (don't forget to add your hook_theme in your custom_module.module).

/**
   * Display content.
   *
   * @return array
   *   Return controller response.
   */
  public function content() {
    $build['#attached'] = [
      'library' => [
        'comment/drupal.comment-new-indicator',
        'history/mark-as-read'
    ];
    
    // Here we load the nodes we want the comments from.
    $nodes = loadYourNodes();
   
    /** @var \Drupal\node\Entity\Node $node */
    foreach ($nodes as $node) {
      // Get the comments from the node.
      $comments = array_reverse($comment_storage->loadThread($node, 'comment', CommentManagerInterface::COMMENT_MODE_FLAT));
      foreach ($comments as $comment) {
        // Creates a variable that we will pass to twig with all the informations we need to create our inbox.
        $messages[$node->id()][] = [
          'from' => $comment->getOwner()->getAccountname(),
          'text' => Markup::create($comment->get('comment_body')->getValue()[0]['value']),
          'date' => CoreAgrieasyUtility::formatDate($comment->getCreatedTime()),
          'timestamp' => $comment->getCreatedTime(),
        ];
      }
      // Add nodes to history to be marked as read.
      // It is needed to pass the array of node ids to drupalSettings.history.nodesToMarkAsRead,
      // as it is in this global variable that the javascript from history module will retreive what was read.
      $build['#attached']['drupalSettings']['history']['nodesToMarkAsRead'][$node->id()] = $node->id();
    }

    // Pass our variable to the theme.
    $build['#content'] = $messages ?? FALSE;

    // Defines custom theme.
    $build['#theme'] = 'controller__mailbox';
}

Then in our custom template controller--mailbox.html.twig we have to set two attributes "data-history-node-id" so comment/drupal.comment-new-indicator can retrieve from the node id, the last time the current user have visited this node in the history database table and "data-comment-timestamp", which is when the comment was created. By comparing when the comment was posted to the last time the user visited the node, the "new" indicator will be added automatically.

Here is a quick example of a controller-mailbox.html.twig:

{#
/**
 * @file
 * Mailbox template.
 *
 * Available variables:
 * - messages: The messages.
 */
#}

<article id="mailbox" {{ attributes.addClass('mailbox') }}>
  {% if messages %}
    {% for nid, messages in content %}
      {# Wrap our messages (comments) in a div with data-history-node-id attribute. The javascript is using closest() function to retreive this attribute from the data-comment-timestamp attribute, so the structure doesn't really matter #}
       <div data-history-node-id="{{ nid }}">
       {% for message in messages %}
         <div>{{ message.text }}</div>
         {# Adds the data-comment-timestamp attribute to a span with a hidden class (important). This will automatically display the "new" indicator if the nid set in data-history-node-id was visited the last time before the value of this timestamp. #}
         <span class="hidden" data-comment-timestamp="{{ message.timestamp }}"></span>
       {% endfor %}
      </div>
    {% endfor %}
  {% endif %}
</article>

When loading the page, comment/drupal.comment-new-indicator library will compare each comment timestamp to the last time the node was viewed by the current user to append or not the indicator. After the page is loaded, the list of node from drupalSettings.history.nodesToMarkAsRead are fetched by history/mark-ad-read and every node will be marked as viewed for the last time at this specific request timestamp for the current user. Hence, the second time the user reload the page, the "new" indicator will disappear.

About the writer

victor.bourgade

Victor is a web developer passionnated in drupal and bootstrap technologies. He likes challenges and beautiful designs.

When not behind his computer you'll find him drinking beers with friends or in the middle of nowhere hiking with his dog.