PhoenixKitComments

Elixir License: MIT

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

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

  1. Add the dependency to mix.exs
  2. Run mix deps.get and mix phoenix_kit.update
  3. Enable the module on the admin Modules page (the comments_enabled setting)
  4. Embed the CommentsComponent in your LiveViews
  5. 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:

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:

Permissions

The module declares permissions via permission_metadata/0:

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 (![alt](url)) 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

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

CSS classes missing

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

Permission denied errors

License

MIT — see LICENSE for details.