This post is part of a series on building blogmate.io, a rich comment system for static blogs and sites. If you have a static blog, or are thinking of starting one, please go check it out and spread the word!

The main component of blogmate.io is its embeddable comment system, which you can see at the bottom of this page. I made a decision early on that threaded comments are a must and a P0 feature. I wanted to be able to support nested comments of any arbitrary depth, in theory at least, with a simple data model and rendering algorithm. After some cursory reading, I settled on the following schema (simplified).

Comment
id
parent_id
body

For top-level comments, the parent_id attribute would be set to nil. For nested comments, parent_id would be set to the id of the child component’s direct parent. If you think of each comment being a node, it will become apparent that this is a graph, a DAG to be more precise.

How would we render such a structure? By using DFS of course! DFS itself is naturally expressed using recursion and because we don’t have to worry about cycles, there’s no need to keep track of visited nodes, making our traversal algorithm super simple.

As you’ve guessed, blogmate.io is built with Elixir and Phoenix LiveView, with pages being composed of reusable Phoenix Components. It may seem tricky at first to recurse inside a component as Phoenix Components are restricted to an arity of one, with the only allowed argument being the assigns map. However, you can get around this by reassigning before returning the HEEX template as documented here. Technically, these limitations are mostly centered around LiveViews. Traditional views can reference variables outside the assigns map as change-tracking isn’t needed. However, doing so is considered bad form and the compiler will throw a warning.

With that in mind, let’s take a look at some code to put these concepts into action. We’ll use a traditional controller-based view in case you’re unfamiliar with LiveView.

Code

comment_controller.ex

defmodule DemoWeb.CommentController do
  use DemoWeb, :controller

  def index(conn, _params) do
    comments = [
      %{parent_id: nil, id: 0, body: "hello"},
      %{parent_id: 0, id: 1, body: "hello to you"},
      %{parent_id: 0, id: 3, body: "hi there"},
      %{parent_id: 1, id: 2, body: "how do ya do sir"},
      %{parent_id: nil, id: 4, body: "i like cheese"}
    ]

    grouped_comments = Enum.group_by(comments, & &1.parent_id)

    render(conn, grouped_comments: grouped_comments)
  end
end

comment_html.ex

Notice here how we make the root attr default to nil. This means we won’t have to pass in a value for @root in our HEEX template. We’ll also color-code the comments by ancestor just to make things a little more clear.

defmodule DemoWeb.CommentHTML do
  use DemoWeb, :html
  use Phoenix.Component
  embed_templates "demo_html/*"

  @colors {
    "bg-blue-100",
    "bg-yellow-100",
    "bg-green-100"
  }

  attr :root, :any, default: nil
  attr :grouped_comments, :any, required: true

  def dfs(assigns) do
    assigns =
      assign(
        assigns,
        :nodes,
        Map.get(assigns.grouped_comments, get_key(assigns.root), [])
      )

    ~H"""
    <div :if={@root} class={get_class((@root.parent_id || -1) + 1)}>
      <p>{"comment id: #{@root.id}"}</p>
      <p><strong>{@root.body}</strong></p>
    </div>
    <div :for={node <- @nodes} class="m-6">
      <.dfs root={node} grouped_comments={@grouped_comments} />
    </div>
    """
  end

  defp get_key(nil), do: nil
  defp get_key(root), do: root.id

  defp get_class(id) do
    "#{elem(@colors, id)} h-20 w-96 border border-4 border-black flex flex-col items-center justify-center"
  end
end

index.html.heex

<.dfs grouped_comments={@grouped_comments} />

Result

If you try this out yourself, you should see the following.

If you have you any questions, or suggestions, feel free to add a comment below 😉. Happy coding and Merry Christmas!