{distill} Comments

Following on from a post on including a contact form on a {distill} site hosted on RStudio Connect, here's a post on how to include comments in blog posts.

Commenting consists of two parts - a way to retrieve comments that belong to a page and a form to enter new comments.

In this implementation I connect a {distill} blog to a {pins} data frame via a {plumber} API. The pinned data frame holds the comments and comments can be added or retrieved through the API. The distill blog posts call javascript functions to post and retrieve comments. The blog, pin board and plumber API all sit on the same RStudio Connect instance.

New Comment Form

The function comment_form 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 and page_id to a plumber API. The plumber API updates a pinned data frame 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 function adds a div with the id rtncomments which is a placeholder to display comments.
The comment_form R function along with the javascript eventListener are shown below. In the code, /addcomment refers to the plumber API endpoint for adding a new comment.

 1library(htmltools)
 2
 3comment_form <- 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" 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      
30          <span class="button-container">
31            <input type="submit" value="Comment">
32          </span>
33        </div>
34      </form>
35    </div>
36      <div id="rtncomments">
37    </div>
38  </div>
39  ')
40  htmltools::HTML(comment_html)
41}
 1window.addEventListener("load", function() {
 2  
 3  document.getElementById("my-form").addEventListener("submit", formsubmit);
 4  
 5  async function formsubmit(e) {
 6    
 7    e.preventDefault();
 8    
 9    // get event-handler element
10    const form = e.currentTarget;
11    
12    // get form url
13    const url = form.action;
14    
15    // get form data as json string
16    formdata = new FormData(form);
17    const plainFormData = Object.fromEntries(formdata.entries());
18    const jsonFormData = JSON.stringify(plainFormData);
19    
20    // send request and capture output
21    out = await fetch(form.action, {
22      method: 'POST',
23      body: jsonFormData,
24      headers: {
25        "Content-Type": "application/json",
26        "Accept": "application/json"
27      }
28    })
29    .then(response => response.json());
30    
31    // update comments
32    update_comments(plainFormData.page_id, plainFormData.site_id);
33
34  };
35
36});

Existing Comments

To retrieve existing comments we use a javascript function to build an HTML output. The function takes site_id and page_id arguments and calls a plumber API which returns comments belonging to the page in json form.
Here, /page_comments 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.
Once the json-formatted response is returned, the function loops through the comments building an HTML response and updates #rtncomments. If a comment does not have an author it defaults to anonymous user.

 1function update_comments(page_id, site_id) {
 2
 3  const url = "<rsconnect URL>/page_comments?"
 4
 5  fetch(url + new URLSearchParams({
 6    site: site_id, 
 7    page: page_id,
 8  }))
 9  .then(response => response.json())  
10  .then(data => {
11    
12    // outer_div - placeholder for comments
13    div_outer = $('<div/>').attr('id', 'div_outer');
14    
15    // add comments count
16    div_outer.append('<h3>' + data.length + ' Comments</h3>');
17
18    // loop through returned comments, adding each one to an unordered list
19    ul_list_comments = $('<ul/>', {id: 'list_comments', class: 'comment-list'});
20
21    $.each(data, function(i, obj) {
22      
23      user_name = obj.user_name == "null" ? "anonymous user" : obj.user_name
24
25      ul_list_comments.append(
26        $('<li/>', {class: 'comment-item'}).append([
27          $('<div/>', {class: 'comment-top'}).append([
28            $('<h3/>', {class: 'comment-name', text: user_name}),
29            $('<span/>', {class: 'date-holder'}).append([
30              $('<i/>', {class: 'far fa-clock'}),
31              $('<h3/>', {class: 'comment-date', text: obj.date})
32            ])
33          ]),
34          $('<p/>', {class: 'comment-text', text: obj.comment})
35        ])
36      );
37    });
38
39    div_outer.append(ul_list_comments);
40
41    $("#rtncomments").html(div_outer);
42
43  })
44  .catch((err) => console.log("Can’t access " + url + " response. Blocked by browser?" + err));
45  
46};

plumber API

The {distill} blog pages are connected to the comments via a plumber API. The API contains two endpoints, a POST endpoint, addcomment which is used to add a new comment and a GET endpoint, page_comments which is used to retrieve comments for a specific page. The comments themselves are stored in a data frame which is accessible via the {pins} package. This allows mutiple sites to use the same data frame.

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. Once again, 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).

blog_comment_table is a data frame with columns for site_id, page_id, user_id, date and comment. The date is a timestamp set when the comment is added.

 1library(plumber)
 2library(jsonlite)
 3library(pins)
 4library(tibble)
 5library(lubridate)
 6library(dplyr)
 7
 8#* @apiTitle Comments
 9
10#* Add a comment to the table
11#* @param req request body
12#* @post /addcomment
13function(req) {
14  
15  ## get the message body
16 body <- jsonlite::fromJSON(req$postBody)
17
18  ## RSConnect API Key
19  connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
20
21  ## register rsconnect pin board
22  board_register("rsconnect",
23                 server = "<rsconnect URL>,
24                 account = "<account id>",
25                 key = connectAPIKey)
26
27  ## check for comments table and create if not present
28  if (nrow(pins::pin_find("blog_comment_table", board = "rsconnect")) == 0) {
29    comments <- tibble(
30      site_id = body$site_id,
31      page_id = body$page_id,
32      user_id = body$user_id,
33      date = lubridate::now(),
34      comment = body$comment
35    )
36  } else {
37    comments <- pins::pin_get(name = "blog_comment_table", board = "rsconnect") %>%
38      add_row(
39        site_id = body$site_id,
40        page_id = body$page_id,
41        user_id = body$user_id,
42        date = lubridate::now(),
43        comment = body$comment
44      )
45  }
46  pins::pin(comments, name = "blog_comment_table", board = "rsconnect")
47  
48}
49
50#* Retrieve all comments for a page
51#* @param site site id
52#* @param page page id
53#* @serializer unboxedJSON
54#* @get /page_comments
55function(site = 0, page = 0) {
56  
57  ## RSConnect API Key
58  connectAPIKey <- Sys.getenv("CONNECT_API_KEY")
59  
60  ## register rsconnect pin board
61  board_register("rsconnect", 
62                 server = "<rsconnect URL>,
63                 account = "<account id>",
64                 key = connectAPIKey)
65  
66  ## get table and filter
67  pins::pin_get(name = "blog_comment_table", board = "rsconnect") %>%
68    dplyr::filter(site_id == site & page_id == page) %>%
69    dplyr::arrange(desc(date))
70  
71}

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 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: "test article number 1"
 3    description: |
 4      A first test article with comments.
 5    author:
 6      - name: Harvey Lieberman
 7    date: 01-10-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    page_id <- 1
20    site_id <- 101
21    source(here::here("comment.R"))
22    htmltools::includeCSS(here::here("style.css"))
23    htmltools::includeScript(here::here("comments.js"))
24    ```
25
26    This is a first blog post with comments.
27
28    ```{r}
29    comment_form(page_id = page_id, site_id = site_id)
30    ```
31
32    <script>
33    update_comments(page_id = `r page_id`, site_id = `r site_id`)
34    </script>
35

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

Output

After adding a couple of sample blog posts and a few comments the output is shown below. Here, I've added two comments to the first blog post and one to the second. The data frame retrieved from {pins} appears as follows:

site_id page_id user_name date comment
<chr> <chr> <chr> <dttm> <chr>
101 1 Harvey 2022-01-10 22:24:00 This is my first blog comment!
101 1 Harvey 2022-01-10 22:24:34 Blog comments can take a little time to appear once entered - possibly an artefact of RStudio Connect or {pins}
101 2 Harvey 2022-01-10 22:25:17 Here's a comment for blog entry #2

Issues to Resolve

The refresh process is a little slow, sometimes taking a several seconds to load comments. At this point I'm not sure if it is related to the use of fetch or plumber.
This is a first proof-of-concept and certainly requires some more work but the principle works well.