Extending Comments on the Blog

Published: 2024-03-17 11:08 AM

Category: Technology | Tags: comments, code, Jinja, programming, pelican


Something came over me this week and I decided to extend my little commenting system to allow for threaded replies.

Backend

I detailed adding comments using Flask in a previous post and this builds on that work. For replies, I created an association between the original comment and any comment submitted which is flagged as a reply. It looks like this in the database:

# Create an association table to link two comments together
comment_replies = db.Table(
    "comment_replies",
    db.metadata,
    db.Column("original_id", db.Integer, db.ForeignKey("comment.id"), primary_key=True),
    db.Column("reply_id", db.Integer, db.ForeignKey("comment.id"), primary_key=True),
)

class Comment(db.Model):
    # Rest of the model properties...

    # Store the linked comments in a list on any comment by ID
    replies = db.relationship(
        "Comment",
        secondary="comment_replies",
        primaryjoin=(comment_replies.c.original_id == id),
        secondaryjoin=(comment_replies.c.reply_id == id),
        lazy="dynamic",
    )

This links the requested comment to any other comment in the database. These are stored in a list which can be filtered and accessed in the template. To avoid making a new POST route for comments, replies are noted with a reply_id= querystring. If the query exists, the new comment is associated with the parent's ID.

Frontend

The frontend was more complicated to do. I originally built the comment module as a custom element, which worked well for that first implementation. It turned into a much more complicated problem in this case because of they way I was rendering comments with a template expression:

render(slug) {
  if (this.comments) {
    this.comments
      .map((comment) => {
        this.insertAdjacentHTML(
          "afterend",
          `
          <article class="comment">
            span class="comment--meta">
              <p>A thought from <b>${comment.name}</b></p>
            </span>
            <em class="comment--datestamp">${comment.occurred}</em>
            <p>
              ${comment.message}
            </p>
          </article>
          `,
          );
        })
    .join("");
  }
}

Comemnts were requested when the element loaded and returned as JSON. The comment.replies key existed for everything, but some arrays were empty. It was also a pain to inlcude expressions in the template string. After a bunch of trial and error, I decided that I was trying to use the custom element just because and not becaues it really gave me a good solution.

I ended up switching back to htmx to handle the commenting system. The custom element already used a network call to load data, so I'm not adding a request. I also get the added benefit of letting the server process the database and return formatted HTML with all nesting already handled.

htmx offers a load event which provides out-of-the-box lazy loading. The blog post will load before comments are requested, so there is no waiting to begin reading the content.

Since every comment can also have comments, this was a good place for some recursion. This led me to create my first Jinja macro which let me define a comment template once which can recurse through the entire reply tree if it exists.

{% macro render_comment(comment) -%}
<article class="comment">
  <span class="comment--meta">
    <p>A thought from <b>{{ comment.name }}</b></p>
  </span>
  <em class="comment--datestamp">{{comment.occurred}}</em>
  <p>{{ comment.message }}</p>
  <button
    class="btn"
    hx-get="{{ config.COMMENTS_ENDPOINT }}/comments/{{ comment.slug }}/reply/{{ comment.id }}"
    hx-target="next div"
    hx-swap="innerHTML"
    hx-trigger="click"
  >
    Reply
  </button>
  <div class="reply"></div>
  <!-- Jinja macros can call themselves -->
  {% if comment.has_replies %} 
    {% for reply in comment.replies %} 
      {% if reply.approved %} 
        {{ render_comment(reply) }} 
      {% endif %} 
    {% endfor %} 
  {% endif %}
</article>
{% endmacro %}

The comment template returned by Flask is two lines and uses the macro to render any comment recursively, attaching approved replies to the parent:

{% from 'shared/macros.html' import render_comment %} 
{% for comment in comments%} 
  {{ render_comment(comment) }} 
{% endfor %}

I was initially frustrated that I couldn't get the custom element to work the way I wanted, but once I decied to switch, this came together much faster and is more robust, I think. It's not clever, but it's easy to follow. I don't have to worry about Javascript rendering along with the server rendering posts and it all just plays nicely.

On to the next project...

Share this post
Previous: Basement Shows and Lost Music Next: Firefox After a Month

Comments