Resource-agnostic, polymorphic commenting module for PhoenixKit. Drop-in comments with unlimited nested threading, like/dislike reactions, moderation, and an admin dashboard.
- Polymorphic comments — attach comments to any resource via
(resource_type, resource_uuid)with zero schema coupling - Unlimited nested threading — self-referencing
parent_uuidwith automatic depth tracking - Like/dislike reactions — one per user per comment, with denormalized counters and transaction-safe updates
- Moderation — optional approval workflow; comments start as
"pending"when moderation is enabled - Admin dashboard — search, filter by status/resource type, paginate, and perform bulk actions
- Auto-discovery — implements
PhoenixKit.Modulebehaviour; PhoenixKit finds it at startup with zero config - LiveView component — embeddable
CommentsComponentfor any page
Add phoenix_kit_comments to your dependencies in mix.exs:
def deps do
[
{:phoenix_kit_comments, "~> 0.3"}
]
endThen fetch dependencies:
mix deps.getNote: 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.
- Add the dependency to
mix.exs - Run
mix deps.get - Enable the module in admin settings (
comments_enabled: true) - Embed the
CommentsComponentin your LiveViews
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}
/>A module whose records are commented on names a handler per resource_type, to
make those records clickable in the moderation admin and to receive lifecycle
notifications. A PhoenixKit module does it from its own PhoenixKit.Module,
with no host configuration:
# in the module that owns the "ticket" records
def resource_links, do: %{"ticket" => MyModule.Tickets}
# or, a link only (no handler, no notifications): a path template
def resource_links, do: %{"widget" => "/admin/widgets/:uuid"}A template declared here is an internal route: give it raw, with no URL prefix, and the prefix and locale are applied when it renders. The Settings template below is for host pages and is used as written.
A host app overrides a module's handler, or registers one of its own types, in config — the config entry wins for its type:
# config/config.exs
config :phoenix_kit, :comment_resource_handlers, %{
"order" => MyApp.Orders
}A link is resolved per type in this order, stopping at the first that answers:
the handler's resolve_comment_resources/1, then a resource_links/0 path
template, then the template under Settings → Comments → Resource Paths (the
no-code option for a host page, rendered as a plain link).
Handler modules can implement:
on_comment_created/3— called after a comment is createdon_comment_published/3— called when a comment held for moderation is approvedon_comment_deleted/3— called after a comment is deletedon_comment_liked/3,on_comment_unliked/3,on_comment_disliked/3,on_comment_undisliked/3— called when a reaction actually changes, never on an "already reacted" no-opresolve_comment_resources/1— returns%{uuid => %{title: ..., path: ...}}for admin displaymoderatable_resources/2—(resource_type, scope)→:allor the resource uuids whose comments a holder of the basecommentskey may moderate. Without it, comments on the type are invisible to them (fail closed)
Uploaded attachments land at the media root by default. To keep them next to the commented record instead, point a hook at the folder:
config :phoenix_kit_comments, :attachments_parent_folder, {MyApp.Media, :parent_for}
# in MyApp.Media
def parent_for(:comment_attachment, _actor_uuid, %{resource_type: "order", resource_uuid: uuid}),
do: {:ok, order_folder_uuid(uuid)}
def parent_for(_kind, _actor_uuid, _subject), do: nilIt is called once per comment. Anything other than {:ok, folder_uuid} leaves the
files at the root, and a hook that raises or exits never fails the comment.
With attachments on, a screenshot or other file pasted into the comment box is
added to the comment's attachments, with the same type, count and size limits as
the attach button. A paste that also carries text (cells copied from a
spreadsheet) is pasted as text. Where a file cannot be attached (attachments
off, or editing an existing comment) the paste is refused with a message in the
form. This runs in the PhoenixKitCommentsPasteFiles hook, which core adds to
the host's LiveSocket from the module's js_sources/0 like the module's other
hooks; no host setup.
A file dragged from the desktop onto the comment box is attached the same way as a pasted one, with the same limits and refusals; the box shows a dashed "Drop files to attach" overlay while the file is over it. (Before, the browser could open the file in the tab instead.) Same hook, no host setup.
An attached file is in use, but the media page cannot tell. Set
config :phoenix_kit, :file_removal_guard, PhoenixKitComments.RemovalGuardand the media browser refuses to trash or delete a file a comment shows, and
says so. Core takes one guard module, so a host with its own calls
PhoenixKitComments.RemovalGuard.places/2 from it and appends the result.
The dialog names only resources the viewer may moderate; other uses still block
removal with a private label. This integration requires a core version providing
PhoenixKit.Modules.Storage.RemovalGuard. Older cores can compile the comments
module and hosts can call places/2 directly.
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, edits, 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 / edit / delete / reaction across every session viewing the resource.
def handle_info({:comments_updated, %{resource_type: _, resource_uuid: _, action: action}}, socket) do
# action is :created | :updated | :deleted | :reaction
{:noreply, refresh_comment_counts(socket)}
endThe 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).
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.
| Key | Type | Default | Description |
|---|---|---|---|
comments_enabled |
boolean | false |
Enable/disable the module |
comments_moderation |
boolean | false |
Require approval for new comments |
comments_rich_text |
boolean | true |
Use the Leaf rich-text editor in the composer (see JavaScript wiring) |
comments_max_depth |
integer | 10 |
Maximum thread nesting level |
comments_max_length |
integer | 10000 |
Maximum comment length (characters) |
When comments_moderation is enabled:
- New comments start with status
"pending" - Admins can approve (set to
"published") or reject (set to"hidden") - Approved comments become visible to all users
- Rejected comments remain hidden but are not deleted
The module declares permissions via permission_metadata/0:
"comments"— the moderation dashboard, limited to comments on records the viewer manages (each handler'smoderatable_resources/2answers which); authors are shown by name, not email"comments.moderate_all"— moderate every comment on the site, with emails"comments.settings"— the settings page (on/off switch, limits, Giphy key, resource paths)
Roles that held "comments" before the sub-keys existed are granted both once,
at boot. Check sub-keys with Scope.can?/2.
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}",
// ...
]
}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
endEmbed uses a lifecycle hook rather than injecting a handle_info/2 clause,
so it composes with a host that already has its own.
handle_info/2, give it a catch-all. The
component also sends {:comments_updated, _} to the host on create, edit,
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}Implement PhoenixKitComments.ResourceHandler to hook into comments on your
records. Every callback is optional:
| Callback | Fires when |
|---|---|
resolve_comment_resources/1 |
the admin list needs titles + links for your records |
on_comment_created/3 |
a comment lands on one of your records |
on_comment_published/3 |
a pending comment is approved (moderation on) |
on_comment_deleted/3 |
a comment is soft-deleted |
on_comment_liked/3 · on_comment_unliked/3 |
reaction added / removed |
on_comment_disliked/3 · on_comment_undisliked/3 |
reaction added / removed |
moderatable_resources/2 |
the moderation page asks which of your records a base-key holder may moderate (:all or uuids) |
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.
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}
/>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
| Status | Description |
|---|---|
"published" |
Visible to all (default when moderation is off) |
"pending" |
Awaiting moderator approval |
"hidden" |
Hidden by a moderator |
"deleted" |
Soft-deleted |
phoenix_kit_comments— comment records (UUIDv7 primary keys)phoenix_kit_comments_likes— like records with unique(comment_uuid, user_uuid)constraintphoenix_kit_comments_dislikes— dislike records with unique(comment_uuid, user_uuid)constraint
mix deps.get # Install dependencies
mix test # Run tests
node --test test/js/comments_hooks_test.cjs # Exercise the shipped browser hooks (Node 20+)
mix format # Format code
mix credo # Static analysis
mix dialyzer # Type checking
mix docs # Generate documentation- Verify
comments_enabledistruein settings - Check that the resource type matches exactly (case-sensitive)
- Ensure the current user is authenticated and passed to the component
- Add
phoenix_kit_commentsto your Tailwind content sources - Run
mix assets.deployto rebuild CSS
- The Leaf rich-text editor's JS hook isn't registered in your
LiveSocket. See JavaScript wiring — import Leaf's JS and spreadwindow.LeafHooksinto your hooks. - Or disable rich text: set
comments_rich_texttofalse, or passrich_text={false}to the component to use the plain-textarea fallback.
- Verify the user has the
"comments"permission (and"comments.settings"for the settings page) - A base-key holder sees only comments on records a handler's
moderatable_resources/2returns
MIT — see LICENSE for details.