{distill} Comments With Replies

This post expands upon the post on {distill} Comments. It includes a method to reply to comments and store comments and replies in an hierarchical manner.

In the previous post I covered how we could use RStudio Connect to manage commenting on a static blog. Here we extend it, adding a way to reply to comments and store comments plus replies in a hiersrchical data structure.

The concept is essentially the same as the earlier version: a {distill} blog is connected to a {pins} data source via plumber. Here, however, the data source is a data.tree as opposed to a data frame. data.tree is an R package that manages hierarchical data and tree structures. Page comments with replies lends itself nicely to a hierarchical data structure where each node is a comment or reply to a comment. The pinned data.tree holds the comments and replies which can be added or retrieved through the API. Comments are retrieved through javascript functions in the distill blog. The blog, pin board and plumber API all sit on the same RStudio Connect instance.

New Comment Form

New comment form is very similar to the original version. The function comment_form_dt takes site_id and page_id arguments and returns an HTML form. site_id is a unique identifier for a website and page_id is a unique identifier for a page on that site.
The form captures a comment and optional user name and passes each of these, along with site_id, page_id and parent_ref to a plumber API. Each comment or reply is given a unique reference number and parent_ref is the reference number of the parent. For page comments parent_ref is simply the page_id but for replies parent_ref is the reference to a comment or a reply. The plumber API updates a pinned data.tree with the new comment. In fact, a javascript function intercepts the submit button triggering an update of the page comments after adding the new one. This allows a new comment to be added without having to refresh the page manually.
In addition, the comment_form_dt function adds a div with the id rtncomments which is a placeholder to display comments.
The comment_form_dt R function along with the javascript eventListener are shown below. In the code, /addcomment_dt refers to the plumber API endpoint for adding a new comment.
The javascript function formsubmit is essentially the same as the earlier function.

 1library(htmltools)
 2
 3comment_form_dt <- function(page_id = 0, site_id = 0) {
 4  
 5  comment_html <- paste0('
 6  <div class="comments">
 7    <div class="form-container">
 8      <h3 class="comment-header">Post a Comment</h3>
 9      <form action="<rsconnect URL>/addcomment_dt" id="my-form">
10      
11        <div class="form-contents">
12          <span class="comment-pic">
13            <i class="far fa-user"></i>
14          </span>
15          
16          <div class="form-details">
17            <div class="comment-comments">
18              <input type="text" id="comment" name="comment" placeholder="Your comment"></textarea>
19            </div>
20            <div class="comment-user">
21              <span class="comment-short">
22                <input type="text" id="user_name" name="user_name" placeholder="Your name (optional)" />
23              </span>
24            </div>
25          </div>
26
27          <input type="hidden" name="site_id" value="', site_id, '" />
28          <input type="hidden" name="page_id" value="', page_id, '" />
29          <input type="hidden" name="parent_ref" value="', page_id, '" />
30      
31          <span class="button-container">
32            <input type="submit" value="Comment">
33          </span>
34        </div>
35      </form>
36    </div>
37      <div id="rtncomments">
38    </div>
39  </div>
40  ')
41  htmltools::HTML(comment_html)
42}
 1window.addEventListener("load", function() {
 2  // add eventlistener to new comment submit button
 3  document.getElementById("my-form").addEventListener("submit", formsubmit);
 4});
 5
 6// Intercept submit button and run fetch code
 7async function formsubmit(e) {
 8  
 9  e.preventDefault();
10  
11  // get event-handler element
12  const form = e.currentTarget;
13  
14  // get form url
15  const url = form.action;
16  
17  // get form data as json string
18  formdata = new FormData(form);
19  const plainFormData = Object.fromEntries(formdata.entries());
20  const jsonFormData = JSON.stringify(plainFormData);
21  
22  // send request and capture output
23  out = await fetch(url, {
24    method: 'POST',
25    body: jsonFormData,
26    headers: {
27      "Content-Type": "application/json",
28      "Accept": "application/json"
29    }
30  })
31  .then(response => response.json());
32  
33  // update comments
34  update_comments_dt(plainFormData.page_id, plainFormData.site_id);
35
36};
37

Existing Comments

Retrieving existing comments introduces a new function to build replies and a reply box for each comment/reply. The main function takes site_id and page_id arguments and calls a plumber API which returns comments belonging to the page in json form. A recursive function then builds comments and any replies, terminating each tree branch with a reply box.
Here, /page_comments_dt refers to the plumber API endpoint for retrieving comments. The search parameters site_id and page_id are appended to the url so that we can limit the returning data to a specific page on a specific site. Since we are using fetch, the webpage and API must live on the same RStudio Connect instance.

  1// build and populate comment reply box
  2function reply_comment_box(page_id, site_id, parent_ref) {
  3  var out = $('<div/>', {class: 'form-container'}).append([
  4    $('<h5/>', {class: 'comment-header comment-header-margin-narrow', text: 'Post a reply'}),
  5    $('<form/>', {action: 'https://rsconnect-prod.dit.eu.novartis.net/content/1200/addcomment_dt', method: 'POST', class: 'reply-form'}).append([
  6      $('<div/>', {class: 'form-contents'}).append(
  7        $('<span/>', {class: 'comment-pic'}).append($('<i/>', {class: 'far fa-user'})),
  8        $('<div/>', {class: 'form-details'}).append(
  9          $('<div/>', {class: 'comment-comments'}).append(
 10            $('<input/>', {type: 'text', name: 'comment', placeholder: 'Your reply'})
 11          ),
 12          $('<div/>', {class: 'comment-user'}).append(
 13            $('<span/>', {class: 'comment-short'}).append(
 14              $('<input/>', {type: 'text', name: 'user_name', placeholder: 'Your name (optional)'})
 15            )
 16          )
 17        ),
 18        $('<input/>', {type: 'hidden', name: 'site_id', value: site_id}),
 19        $('<input/>', {type: 'hidden', name: 'page_id', value: page_id}),
 20        $('<input/>', {type: 'hidden', name: 'parent_ref', value: parent_ref}),
 21        $('<span/>', {class: 'button-container'}).append(
 22          $('<input/>', {type: 'submit', value: 'submit'})
 23        )
 24      )
 25
 26    ])
 27  ])
 28  return(out)
 29};
 30
 31
 32// update comments on the page
 33function update_comments_dt(page_id, site_id) {
 34
 35  const url = "<rsconnect URL>/page_comments_dt?"
 36
 37  fetch(url + new URLSearchParams({
 38    site: site_id, 
 39    page: page_id,
 40  }))
 41  .then(response => response.json())  
 42  .then(data => {
 43    
 44    // recursive function to print comments
 45    function comment_recurse(d) {
 46      if (d.hasOwnProperty('children')) {
 47        const ul_list_comments = $('<ul/>', {class: 'comment-list'});
 48
 49        // loop over children (replies) and populate
 50        $.each(d.children, function(i, x) {
 51          user_name = x.user_name == "null" ? "anonymous user" : x.user_name;
 52          style_txt = 'margin-left: 20px;'
 53          ul_list_comments.append(
 54            $('<li/>', {class: 'comment-item', style: style_txt}).append([
 55              $('<div/>', {class: 'comment-top'}).append([
 56                $('<h3/>', {class: 'comment-name', text: user_name}),
 57                $('<span/>', {class: 'date-holder'}).append([
 58                  $('<i/>', {class: 'far fa-clock'}),
 59                  $('<h3/>', {class: 'comment-date', text: x.date})
 60                ])
 61              ]),
 62              $('<p/>', {class: 'comment-text', text: x.comment}),
 63              $('<details/>').append([
 64                $('<summary/>', {class: 'text-reply', text: 'reply'}),
 65                reply_comment_box(x.page_id, x.site_id, x.ref)
 66              ]),
 67              comment_recurse(x)
 68            ]),
 69
 70          );
 71          
 72        });
 73        return(ul_list_comments)
 74      } else {
 75        return(null)
 76      }
 77      
 78    }
 79      
 80    // outer_div - placeholder for comments
 81    div_outer = $('<div/>').attr('id', 'div_outer');
 82    
 83    // add comments if exist
 84    if (data.children) {
 85      // add comments count
 86      div_outer.append('<h3>' + data.children.length + ' Comments</h3>');
 87      
 88      // recursively loop through returned comments, building unordered lists
 89      ul_list_comments = comment_recurse(data);
 90      
 91      // add comments to outer div
 92      div_outer.append(ul_list_comments);
 93    }
 94    
 95    // update comment holder
 96    $("#rtncomments").html(div_outer);
 97    
 98    // add event listener to class
 99    const reply_forms = document.querySelectorAll('.reply-form');
100    reply_forms.forEach(item => item.addEventListener('submit', formsubmit));
101    
102  })
103  .catch((err) => console.log("Can’t access " + url + " response. Blocked by browser?" + err));
104  
105};

plumber API

As previously, the distill blog pages via a plumber API. The API contains two endpoints, a POST endpoint, addcomment_dt which is used to add a new comment and a GET endpoint, page_comments_dt which is used to retrieve comments for a specific page. The comments are stored in a hierarchical data.tree format which is accessible via the {pins} package.

In the code below, board_register("rsconnect", server = "<rsconnect URL>, account = "<account id>", key = connectAPIKey) registers a pin board which holds a pin called blog_comment_table+dt. refers to the RStudio Connect URL and, is the account associated with the pin. An RStudio Connect API key must be defined and exposed as an environment variable (see below).

addcomment_dt adds the comment to a parent id which sits under a page id, that is, in turn, under a site id. Each comment is given a unique reference id, used as an identifier when comments or replies are added.

page_comments_dt retrieves a hierarchy of comments and replies for a specified site id and page id. The data.tree obtained is returned as a list.

  1library(plumber)
  2library(jsonlite)
  3library(pins)
  4library(lubridate)
  5library(data.tree)
  6library(stringi)
  7
  8#* Add a comment to the comment table
  9#* 
 10#* @param req request body
 11#* 
 12#* @serializer unboxedJSON
 13#* 
 14#* @post /addcomment_dt
 15function(req) {
 16  
 17  ## get the message body
 18  body <- jsonlite::fromJSON(req$postBody)
 19  
 20  ## RSConnect API Key
 21  connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
 22  
 23  ## register rsconnect pin board
 24  board_register("rsconnect",
 25                 server = "<rsconnect URL>",
 26                 account = "<account id>",
 27                 key = connectAPIKey)
 28  
 29  ## generate a ref for the comment
 30  comment_ref <- stringi::stri_rand_strings(n = 1, length = 12)
 31  
 32  comment <- c(
 33    body,
 34    list(
 35      ref = comment_ref,
 36      date = lubridate::now()
 37    )
 38  )
 39  
 40  ## check for comments table and create if not present
 41  if (nrow(pins::pin_find("blog_comment_table_dt", board = "rsconnect")) == 0) {
 42    comment_tree <- Node$new("comments")
 43  } else {
 44    comment_tree <- pins::pin_get(name = "blog_comment_table_dt", board = "rsconnect") 
 45  }
 46  
 47  ## does site_id child node exist?
 48  if (is.null(FindNode(comment_tree, comment$site_id))) {
 49    comment_tree$AddChild(comment$site_id)
 50  }
 51  
 52  ## does page_id child node exist?
 53  site_node <- FindNode(comment_tree, comment$site_id)
 54  if (is.null(FindNode(site_node, comment$page_id))) {
 55    site_node$AddChild(comment$page_id)
 56  }
 57  
 58  ## add new comment
 59  if (!is.na(comment$parent_ref)) {
 60    parent_node <- FindNode(site_node, comment$parent_ref)
 61  } else {
 62    parent_node <- FindNode(site_node, comment$page_id)
 63  }
 64  do.call(parent_node$AddChild, c(list(name = comment$ref), comment))
 65  
 66  pins::pin(comment_tree, name = "blog_comment_table_dt", board = "rsconnect")
 67  
 68  return(comment)
 69}
 70
 71
 72#* Retrieve all comments for a page
 73#* 
 74#* @param site site id
 75#* @param page page id
 76#* 
 77#* @serializer unboxedJSON
 78#* 
 79#* @get /page_comments_dt
 80function(site = "site_01", page = "page_01") {
 81  
 82  ## RSConnect API Key
 83  connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
 84  
 85  ## register rsconnect pin board
 86  board_register("rsconnect", 
 87                 server = "https://rsconnect-prod.dit.eu.novartis.net",
 88                 account = "liebeha1",
 89                 key = connectAPIKey)
 90  
 91  ## get table and filter
 92  rtn_subtree <- list()
 93  if (nrow(pins::pin_find("blog_comment_table_dt", board = "rsconnect")) > 0) {
 94    
 95    ## get pinned comment tree
 96    comment_tree <- pins::pin_get(name = "blog_comment_table_dt", board = "rsconnect")
 97    
 98    ## is site in comment tree?
 99    if (!is.null(FindNode(comment_tree, site))) {
100      
101      ## is page in comment tree and does it have comments?
102      found_page_comments <- FindNode(comment_tree[[site]], page)
103      if (!is.null(found_page_comments)) {
104        rtn_subtree <- as.list(found_page_comments, 
105                               mode = "explicit", unname = TRUE)
106      }
107      
108    }
109  }
110  return(rtn_subtree)
111}
data.tree with comments illustrating hierarchy.  Data are nested as comments, replies, replies-to-replies, etc.  For example, page_01 contains two comments: R8VkpR08pQTA (with a reply cVGBQzLRV9pa) and lHcoISddQbJp

data.tree with comments illustrating hierarchy. Data are nested as comments, replies, replies-to-replies, etc. For example, page_01 contains two comments: R8VkpR08pQTA (with a reply cVGBQzLRV9pa) and lHcoISddQbJp

output from data.tree illustrating the metadata held at each node

output from data.tree illustrating the metadata held at each node

Webpage / Blog Post with Comments

Any page with comments follows the same approach. The page includes the javascript functions listed above (comments.js), some css styling (style.css, see below) and the comment_form function (sourced from comment.R).
There are a few things to note in the code below.

  • The two variables, site_id and page_id, are needed to identify comments for the webpage. Ideally, we'd define them in the yaml header and use them as parameters in the markdown text. Unfortunately, when using render_site, markdown parameters are not rendered (see open GitHub issue). site_id and page_id are therefore defined within a chunk.
  • The javascript function update_comments_dt does not sit in a javascript chunk (you can include javascript in rmarkdown by including a chunk with js instead of r in the chunk header). Instead, the code is placed directly within a <script> tag. When processed this way, we can access variables (site_id and page_id) stored in r language chunks earlier in the document.
 1    ---
 2    title: "article 1"
 3    description: |
 4      Blog post #1.
 5    author:
 6      - name: Harvey Lieberman
 7    date: 03-03-2022
 8    output:
 9      distill::distill_article:
10        self_contained: false
11    ---
12
13
14    ```{r setup, include=FALSE}
15    knitr::opts_chunk$set(echo = FALSE)
16    ```
17
18    ```{r}
19    ## define site and page
20    page_id <- "page_01"
21    site_id <- "site_02"
22
23    ## add function, css and js to page
24    source(here::here("comment_dt.R"))
25    htmltools::includeCSS(here::here("style.css"))
26    htmltools::includeScript(here::here("comment_dt.js"))
27    ```
28
29    This is a typical blog post but with a comment section added.  
30    Comments include nested replies.
31
32
33    ```{r}
34    ## include comment form
35    comment_form_dt(page_id = page_id, site_id = site_id)
36
37    ## js below placed in script tags so that R variable can be included
38    ```
39
40    <script>
41    update_comments_dt(page_id = "`r page_id`", site_id = "`r site_id`")
42    </script>
43

css

The style.css file takes care of styling comments. The file is included below.

  1.comments {
  2  padding: 20px 10px;
  3  margin: 0;
  4}
  5
  6.form-container input[type=submit] {
  7  background-color: #04AA6D;
  8  color: white;
  9  padding: 12px 20px;
 10  border: none;
 11  border-radius: 4px;
 12  cursor: pointer;
 13}
 14
 15.form-container input[type=submit]:hover {
 16  background-color: #45a049;
 17}
 18
 19.comment-header {
 20  font-size: 1.5em;
 21  line-height: 1.5em;
 22  font-weight: 400;
 23  margin-block-start: 1.5em;
 24  margin-block-end: 1.5em;
 25}
 26
 27.comment-header-margin-narrow {
 28  margin-block-start: 0.5em;
 29  margin-block-end: 0.5em;
 30}
 31
 32.form-contents {
 33    padding: 10px;
 34    margin: 10px;
 35    display: flex;
 36    flex-direction: row;
 37    align-items: center;
 38}
 39
 40.form-contents .comment-pic {
 41    display: flex;
 42    font-size: 3em;
 43    align-self: flex-end;
 44}
 45
 46.form-details {
 47    display: flex;
 48    flex-direction: column;
 49    flex: 2 1 auto;
 50}
 51
 52.form-details input[type=text] {
 53    border-top: 0px;
 54    border-bottom: 1px solid #ccc;
 55    border-left: 0px;
 56    border-right: 0px;
 57    outline: 0px;
 58    padding: 0;
 59    margin-top: 20px;
 60    margin-left: 20px;
 61    font-weight: normal;
 62}
 63
 64.form-details input[type=text]:focus {
 65    border-color: #04AA6D;
 66    border-width: 2px;
 67}
 68
 69.comment-comments input[type=text]{
 70    width: 90%;
 71}
 72
 73.comment-user {
 74    display: flex;
 75    flex-direction: row;
 76}
 77
 78.comment-short {
 79    width: 50%;
 80}
 81
 82.comment-short input[type=text]{
 83    width: 80%;
 84}
 85
 86.button-container {
 87    display: flex;
 88    align-self: flex-end;
 89}
 90
 91.button-container input[type=submit] {
 92  margin: 2px 5px;
 93  float: right;
 94}
 95
 96.comment-holder {
 97  margin-top: 50px;
 98}
 99
100ul.comment-list {
101  list-style: none;
102  position: relative;
103  padding: 0;
104  border: 1px solid #ccc;
105}
106
107li.comment-item {
108  padding: 20px 10px;
109  margin: 20px 0;
110  position: relative;
111}
112
113.comment-top {
114  display: flex;
115  flex-direction: row;
116  justify-content: space-between;
117}
118
119.comment-name {
120  font-size: 1.5em;
121  font-weight: 400;
122  margin: 5px 0;
123  color: #5d5d5d;
124  align-self: flex-start;
125}
126
127.date-holder {
128  color: #5d5d5d;
129  align-self: flex-end;
130  display: inline-flex;
131  align-items: baseline;
132}
133
134.comment-date {
135  font-size: 1em;
136  font-weight: 400;
137  margin: 5px 0 5px 10px;
138}
139
140.comment-text {
141  display: block;
142  margin: 0 0 10px 0;
143}

Output

The follow screen captures illustrate adding comments and replies.

First comment add to a blog post

First comment add to a blog post

Adding a reply added to comment #1

Clicking on the Reply dropdown opens a reply window

Once the reply is added it also includes a dropdown for nesting replies

Reply for comment #2 dropdown opened

Conclusion

RStudio Connect can be used with {pins} to hold nested comments for blog pages. This demonstrates the huge scope that RStudio Connect can play as a CMS.