PhoenixKitComments

ElixirLicense: MIT

Resource-agnostic, polymorphic commenting module for PhoenixKit. Drop-in comments with unlimited nested threading, like/dislike reactions, moderation, and an admin dashboard.

Features

Installation

Add phoenix_kit_comments to your dependencies in mix.exs:

def deps do
[
{:phoenix_kit_comments, "~> 0.3"}
]
end

Then fetch dependencies:

mix deps.get

Note: For development or if not yet published to Hex, you can use:

{:phoenix_kit_comments, github: "mdon/phoenix_kit_comments"}

PhoenixKit auto-discovers the module at startup — no additional configuration needed.

Quick Start

  1. Add the dependency to mix.exs
  2. Run mix deps.get
  3. Enable the module in admin settings (comments_enabled: true)
  4. Embed the CommentsComponent in your LiveViews

Usage

Embedding comments on a page

Use the CommentsComponent LiveComponent in any LiveView:

<.live_component
module={PhoenixKitComments.Web.CommentsComponent}
id="comments"
resource_type="post"
resource_uuid={@post.uuid}
current_user={@current_user}
/>

Resource handler callbacks

Modules that consume comments can register handlers to receive lifecycle notifications:

# config/config.exs
config :phoenix_kit, :comment_resource_handlers, %{
"post" => PhoenixKitPosts,
"entity" => PhoenixKitEntities
}

Handler modules can implement:

Live updates across sessions

CommentsComponent keeps the posting user's own view fresh automatically. To also update other connected users (e.g. a comment-count badge or an open thread on another screen) when anyone comments, deletes, or reacts, subscribe the host LiveView to the resource's comment activity:

def mount(_params, _session, socket) do
# Subscribe in the connected branch only — mount runs twice.
if connected?(socket) do
PhoenixKitComments.subscribe("order", order_uuid)
end
{:ok, socket}
end
# Fired for create / delete / reaction across every session viewing the resource.
def handle_info({:comments_updated, %{resource_type: _, resource_uuid: _, action: action}}, socket) do
# action is :created | :deleted | :reaction
{:noreply, refresh_comment_counts(socket)}
end

The broadcast payload mirrors the {:comments_updated, …} message the component already sends to its own host, so you have one message contract for both local and remote updates. The PubSub server is resolved via PhoenixKit.PubSubHelper (configure with config :phoenix_kit, pubsub: MyApp.PubSub).

Counting comments for many resources at once

When rendering a list of commentable resources (e.g. one count badge per row), pass a list of UUIDs to count_comments/3 to get a uuid => count map in a single grouped query instead of N separate counts:

# One query; every requested uuid is present, missing ones as 0.
PhoenixKitComments.count_comments("order", [uuid_a, uuid_b, uuid_c])
#=> %{uuid_a => 3, uuid_b => 0, uuid_c => 7}

It honors the same :status / :include_deleted options as the scalar form.

Settings

KeyTypeDefaultDescription
comments_enabledbooleanfalseEnable/disable the module
comments_moderationbooleanfalseRequire approval for new comments
comments_rich_textbooleantrueUse the Leaf rich-text editor in the composer (see JavaScript wiring)
comments_max_depthinteger10Maximum thread nesting level
comments_max_lengthinteger10000Maximum comment length (characters)

Moderation Workflow

When comments_moderation is enabled:

Permissions

The module declares permissions via permission_metadata/0:

Use Scope.has_module_access?/2 to check permissions in your application.

CSS Requirements

For Tailwind CSS users: ensure phoenix_kit_comments is listed in your tailwind.config.js sources:

module.exports = {
content: [
// ...
"./deps/phoenix_kit_comments/**/*.{heex,ex}",
// ...
]
}

Embedding the composer (required for rich text)

The comment composer's rich-text editor lives in a separate LiveComponent, and its {:leaf_changed, _} messages arrive at the HOST LiveView. Without forwarding them the composer looks fine and "Post comment" silently posts nothing.

defmodule MyAppWeb.OrderLive do
use MyAppWeb, :live_view
use PhoenixKitComments.Embed # attaches the forwarding hook
end

Embed uses a lifecycle hook rather than injecting a handle_info/2 clause, so it composes with a host that already has its own.

⚠️ If you write your own handle_info/2, give it a catch-all. The component also sends {:comments_updated, _} to the host on create and delete. LiveView only tolerates unmatched messages when a view exports NO handle_info/2 — the moment you define one clause, an unmatched message raises FunctionClauseError and kills the LiveView.

def handle_info({:comments_updated, _payload}, socket), do: {:noreply, socket}
def handle_info(_msg, socket), do: {:noreply, socket}

Resource handler callbacks

Implement PhoenixKitComments.ResourceHandler to hook into comments on your records. Every callback is optional:

CallbackFires when
resolve_comment_resources/1the admin list needs titles + links for your records
on_comment_created/3a comment lands on one of your records
on_comment_deleted/3a comment is soft-deleted
on_comment_liked/3 · on_comment_unliked/3reaction added / removed
on_comment_disliked/3 · on_comment_undisliked/3reaction added / removed

resolve_comment_resources/1 returns %{uuid => %{title:, path:}} and may add :full_title and :thumb_url. path is RAW — the renderer applies the PhoenixKit url prefix once, so a pre-prefixed path is doubled.

JavaScript wiring

The comment composer's optional features rely on JS hooks that the host application must register in its LiveSocket. If a hook isn't registered, the feature that uses it won't work — most notably the Leaf rich-text editor will hang on its loading text with no server-side error or log line.

In your app.js:

// Leaf rich-text editor (used by the comment composer when comments_rich_text is on)
import "../../deps/leaf/priv/static/assets/leaf.js"
let liveSocket = new LiveSocket("/live", Socket, {
params: { _csrf_token: csrfToken },
hooks: {
...(window.LeafHooks || {}),
// ...your other hooks
},
})

If you don't want rich text — or can't wire the JS — set comments_rich_text to false in settings, or pass rich_text={false} to the component. The composer then falls back to a plain <textarea>, which needs no JS and always works:

<.live_component
module={PhoenixKitComments.Web.CommentsComponent}
id="comments"
resource_type="post"
resource_uuid={@post.uuid}
current_user={@current_user}
rich_text={false}
/>

Architecture

lib/
phoenix_kit_comments.ex # Context + PhoenixKit.Module behaviour
phoenix_kit_comments/
schemas/
comment.ex # Polymorphic comment schema with threading
comment_like.ex # Like tracking (unique per user per comment)
comment_dislike.ex # Dislike tracking (unique per user per comment)
web/
comments_component.ex # Embeddable LiveComponent
index.ex # Admin moderation dashboard
settings.ex # Admin settings page

Comment statuses

StatusDescription
"published"Visible to all (default when moderation is off)
"pending"Awaiting moderator approval
"hidden"Hidden by a moderator
"deleted"Soft-deleted

Database tables

Development

mix deps.get # Install dependencies
mix test # Run tests
mix format # Format code
mix credo # Static analysis
mix dialyzer # Type checking
mix docs # Generate documentation

Troubleshooting

Comments not appearing

CSS classes missing

Comment editor stuck on a loading word ("Polishing…", etc.)

Permission denied errors

License

MIT — see LICENSE for details.