PhoenixKitComments
Resource-agnostic, polymorphic commenting module for PhoenixKit. Drop-in comments with nested replies, like/dislike reactions, attachments, voice notes, Giphy, moderation, and an admin dashboard.
Features
- Polymorphic comments — attach comments to any resource via
(resource_type, resource_uuid)with zero schema coupling - Nested replies — self-referencing
parent_uuidwith automatic depth tracking, up tocomments_max_depth(default 10) - Like/dislike reactions — one per user per comment, with denormalized counters and transaction-safe updates
- Attachments and voice notes — images, video, audio and files, pasted, dropped or picked, through the host's storage
- Translated — English, German, Spanish, Estonian, French, Italian, Russian and Chinese
- 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
Installation
Add phoenix_kit_comments to your dependencies in mix.exs:
def deps do
[
{:phoenix_kit_comments, "~> 0.6"}
]
end
Then fetch dependencies and create the tables:
mix deps.get
mix phoenix_kit.update
mix phoenix_kit.update applies core's migrations, then this module's own chain
(PhoenixKitComments.Migrations), generating a wrapper migration in your repo's
migrations directory; commit that file. (Core 3.x no longer creates the comments
tables itself; this module's chain does.)
PhoenixKit auto-discovers the module at startup — no router or config entry needed.
Quick Start
- Add the dependency to
mix.exs - Run
mix deps.getandmix phoenix_kit.update - Enable the module on the admin Modules page (the
comments_enabledsetting) - Embed the
CommentsComponentin your LiveViews - With rich text on (the default), forward the editor's messages — see Embedding the composer
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
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)
Attachment folder
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: nil
It 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.
Pasted files
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.
Dropped files
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.
Keeping attachments out of the media trash
An attached file is in use, but the media page cannot tell. Set
config :phoenix_kit, :file_removal_guard, PhoenixKitComments.RemovalGuard
and 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.
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, 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
# The component does not subscribe by itself: tell an open thread to reload.
send_update(PhoenixKitComments.Web.CommentsComponent, id: "comments", loaded?: false)
{: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
| 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 (1–50) |
comments_max_length |
integer | 10000 |
Maximum comment length in characters (100–100,000) |
comments_attachments_enabled |
boolean | false |
Allow attachments and voice notes (pasting and dropping files need it) |
comments_max_attachments |
integer | 4 |
Attachments per comment (1–10) |
comments_attachment_max_size_mb |
integer | 20 |
Size cap per file, clamped by the global storage_max_upload_size_mb |
comments_giphy_enabled |
boolean | false |
Show the Giphy picker (needs an API key) |
comments_giphy_api_key |
string | "" |
Giphy API key |
comments_giphy_rating |
string | "g" |
Maximum Giphy rating: g, pg, pg-13 or r |
All of them are managed from Settings → Comments.
Moderation Workflow
When comments_moderation is enabled:
- New comments start with status
"pending", and the poster is told it awaits approval - Moderators approve (
"published") or hide ("hidden") them, one at a time or in bulk - Approved comments become visible to all users
- Hidden comments remain stored but are not shown; one with published replies
shows as a
[removed]placeholder so the replies stay readable (as for a deleted one) - Approve never brings back a deleted comment; Restore does (back to
"pending"while moderation is on) statusis never taken from params:create_comment/4andupdate_comment/3read it from an atom key only, and the changesets do not cast it
Permissions
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.
CSS
No setup: the module declares css_sources/0, and core's
:phoenix_kit_css_sources compiler (added to your mix.exs compilers by
mix phoenix_kit.install) writes the Tailwind @source for its templates into
assets/css/_phoenix_kit_sources.css.
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, 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}
Resource handler callbacks
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.
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 (one per user, kept by the context)
comment_dislike.ex # Dislike tracking (one per user, kept by the context)
comment_media.ex # A comment's attachments, by position
web/
comments_component.ex # Embeddable LiveComponent
index.ex # Admin moderation dashboard
settings.ex # Admin settings page
markdown.ex # Sanitised markdown renderer
activity.ex # Activity-log lines for every mutation
attachments.ex # Where uploaded attachments are placed
embed.ex # `use` in a host LiveView to forward Leaf events
file_references.ex # Attachment columns for core's orphan-file check
migrations.ex # This module's versioned migration chain
removal_guard.ex # Keeps attached files out of the media trash
resource_handler.ex # Behaviour for resource handlers
Images in comments
Markdown images () are shown inline only from this site (a relative
path or its own host) and from Giphy over HTTPS. Any other image is rendered as a
link to it, so a reader's browser never fetches a third party's image (a tracking
pixel) just by showing the comment. Attachments are unaffected.
Comment statuses
| Status | Description |
|---|---|
"published" |
Visible to all (default when moderation is off) |
"pending" |
Awaiting moderator approval |
"hidden" |
Hidden by a moderator |
"deleted" |
Soft-deleted |
Database tables
phoenix_kit_comments— comment records (UUIDv7 primary keys)phoenix_kit_comments_likes— like records, one per(comment_uuid, user_uuid)phoenix_kit_comments_dislikes— dislike records, one per(comment_uuid, user_uuid)phoenix_kit_comment_media— attachments, linking stored files to comments by position
One reaction per user is kept by like_comment/2 and dislike_comment/2, which
lock the comment row; the tables have no unique index on (comment_uuid, user_uuid), so a row written around those functions can duplicate one.
Development
mix deps.get # Install dependencies
mix test # Run tests (includes the JS hooks' tests when node 20+ is installed)
mix precommit # The gate: compile, unused deps, hex.audit, format check, credo, dialyzer
mix format # Format code
mix credo # Static analysis
mix dialyzer # Type checking
mix docs # Generate documentation
Troubleshooting
Comments not appearing
- Verify
comments_enabledistruein settings - Check that the resource type matches exactly (case-sensitive)
- Ensure the current user is authenticated and passed to the component
CSS classes missing
- Check that
:phoenix_kit_css_sourcesis in yourmix.exscompilers andassets/css/app.cssimports./_phoenix_kit_sources.css - Run
mix assets.deployto rebuild CSS
Comment editor stuck on a loading word ("Polishing…", etc.)
- 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.
Permission denied errors
- 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
License
MIT — see LICENSE for details.