An Idiosyncratic Blog

Adding Comments to my Blog

Published on
11 minutes read

I've given way too much thought on whether I want to have comments for this site. I went thorough many comment systems, and most of them are either too clunky, collect way too much PII data about user, unmanageable spams, etc.

Most of all, I really didn't want to have any external scripts on this site. I still felt the need to have a way to connect with people who read articles on this site. I have the social links available, but since I am not much active on social media (nor famous, with less than 500 Followers on Twitter), having a comment system in place seemed like a no-brainer.

Selecting a comment system

I came across Utterances, an open source project by Jeremy Danyow. A quick glance at the README, and I was sold. It checked almost all the boxes which I had:

  • Open-Source,
  • Does not collect user information and no ads,
  • Light weight and loads instantly
  • Supports Dark Mode

The way Utterances works is, it creates an Issue on behalf of the user in the repo with the URL slug as an identifier for the issue. So if there is already a comment, then new comments will be added to the same issue thread. And since users must authorize using GitHub, probability of having spam comments decreases by a lot. I can have genuine angry comments, instead of spambots linking to OnlyFans accounts.

Setting Up Utterances in Gatsby

As per the instructions in the README, all I have to do is add a script tag and point to the correct repo. And install the Utterances App and grant permission to write and read to a repo which will host all the comments.

Seems pretty straightforward. Only issue is, React does not support adding <script> directly in JSX. So I have to write a component, which dynamically adds the Utterances script to the blog template page.

After about 10 minutes of coding, here's the Comments component.

import React, { useEffect } from 'react'

const commentNodeId = 'comments'

const Comments = () => {
  useEffect(() => {
    const script = document.createElement('script')
    script.src = 'https://utteranc.es/client.js'
    script.async = true
    script.setAttribute('repo', 'PsyGik/blog.dhanrajsp.me-content')
    script.setAttribute('issue-term', 'pathname')
    script.setAttribute('label', 'comment :speech_balloon:')
    script.setAttribute('theme', 'photon-dark')
    script.setAttribute('crossorigin', 'anonymous')

    const scriptParentNode = document.getElementById(commentNodeId)
    scriptParentNode.appendChild(script)

    return () => {
      // cleanup - remove the older script with previous theme
      scriptParentNode.removeChild(scriptParentNode.firstChild)
    }
  }, [])

  return <div id={commentNodeId} />
}

export default Comments

And to use it, I just include the <Comments/> component in the blog template file.

<div className={contentWrapper}>
  <TableOfContents toc={headings}></TableOfContents>
  <PostContent post={html}></PostContent>
  <div className={actionItems}>
    <Chips items={tags} url={true}></Chips>
    <Share url={href} title={title} keywords={tags}></Share>
  </div>
  <Comments />
</div>

Loading comments on-demand

The above implementation works well, but I do not want to load the comments script when someone is at the start of the article. Ideally the comments should show up when they reach the end of the article. That's where Intersection Observer API comes in.

About the Intersection Observer API, straight from the MDN Docs,

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

With Intersection Observer implemented, the Comments component now loads when the page is scrolled to the end of the article. Here's the updated Comments component:

import React, { useEffect, useState } from 'react'

const commentNodeId = 'comments'

const Comments = () => {
  const [visible, setVisible] = useState(false)

  useEffect(() => {
    if (!visible) return
    console.log('Loading Comments')
    // docs - https://utteranc.es/
    const script = document.createElement('script')
    script.src = 'https://utteranc.es/client.js'
    script.async = true
    script.setAttribute('repo', 'PsyGik/blog.dhanrajsp.me-content')
    script.setAttribute('issue-term', 'pathname')
    script.setAttribute('label', 'comment :speech_balloon:')
    script.setAttribute('theme', 'photon-dark')
    script.setAttribute('crossorigin', 'anonymous')

    const scriptParentNode = document.getElementById(commentNodeId)
    scriptParentNode.appendChild(script)

    return () => {
      // cleanup - remove the older script with previous theme
      scriptParentNode.removeChild(scriptParentNode.firstChild)
    }
  }, [visible])

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setVisible(true)
          }
        })
      },
      {
        threshold: 1,
      }
    )
    observer.observe(document.getElementById(commentNodeId))
  }, [])

  return <div id={commentNodeId} />
}

export default Comments

Comments component has a state variable visible. When visible=true, the scripts required by Utterance are injected into the DOM. The Intersection Observer has a callback which tells us when the element is intersecting in the DOM window. The threshold value of 1 means that the callback must be triggered when the element is completely visible in the DOM. Using this callback, the state variable visible is updated.

Note that once visible=true, I do not flip the boolean because the comments should stay on the page once it's loaded.

Next steps are to move the Intersection Observer and the scripts to load Utterance into a hook, so that I can use useUtterances() and the component code looks cleaner.

import { useEffect, useState } from 'react'

export const useUtterances = (commentNodeId) => {
  const [visible, setVisible] = useState(false)

  useEffect(() => {
    if (!visible) return
    // docs - https://utteranc.es/
    const script = document.createElement('script')
    script.src = 'https://utteranc.es/client.js'
    script.async = true
    script.setAttribute('repo', 'PsyGik/blog.dhanrajsp.me-content')
    script.setAttribute('issue-term', 'pathname')
    script.setAttribute('label', 'comment :speech_balloon:')
    script.setAttribute('theme', 'photon-dark')
    script.setAttribute('crossorigin', 'anonymous')

    const scriptParentNode = document.getElementById(commentNodeId)
    scriptParentNode.appendChild(script)

    return () => {
      // cleanup - remove the older script with previous theme
      scriptParentNode.removeChild(scriptParentNode.firstChild)
    }
  }, [visible])

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        entries.forEach((entry) => {
          if (entry.isIntersecting) {
            setVisible(true)
          }
        })
      },
      {
        threshold: 1,
      }
    )
    observer.observe(document.getElementById(commentNodeId))
  }, [commentNodeId])
}

Now for the updated Comments component with useUtterances hook,

import React, { useState } from 'react'
import { useUtterances } from '../../hooks/useUtterances'

const commentNodeId = 'comments'

const Comments = () => {
  useUtterances(commentNodeId)
  return <div id={commentNodeId} />
}

export default Comments

And it's done!

For the experienced React Devs rolling their eyes on the usage of document.getElementById for accessing a DOM node, yes I know. I can (should?) use a useRef(), and use that to access DOM elements.1

This is a very basic implementation of the Intersection Observer API. If you do not want to write a React wrapper like I did, check out react-intersection-observer by Daniel Schmidt which provides a clean API to observe DOM elements.

Fin.

So now I've got a really cool comment system on the blog. It's free, open-source, does not mine user data, prevents spam by using GitHub login, and has a really nice API.

Although I wish there were more theme options, photon-dark is the only one which works well with the color scheme of this site.

Feel free to add your comments ;)

Until next time! ✌🏽

Footnotes

  1. https://reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node