Skip to content

Threaded (nested) comments with a collapsible, connector-line UI - #103

Open
ItsMalikJones wants to merge 3 commits into
mainfrom
feat/11-comment-threading
Open

Threaded (nested) comments with a collapsible, connector-line UI#103
ItsMalikJones wants to merge 3 commits into
mainfrom
feat/11-comment-threading

Conversation

@ItsMalikJones

Copy link
Copy Markdown
Contributor

Closes #11

Summary

Adds opt-in threaded comment replies to Commentions. Users can reply directly
to a comment, replies render recursively beneath their parent, and long
sub-threads can be collapsed. The feature is disabled by default and gated behind
a new config block, so existing installations are unaffected until they opt in.

What's included

Data layer

  • Additive migration — a new nullable, self-referencing parent_id column on
    the comments table (add_parent_id_to_commentions_comments_table.php.stub),
    with a foreign key that cascades on delete.
  • Comment modelparent() / replies() relations, a depth() helper
    that walks the parent chain, and repliesCount() which recursively counts all
    descendants.
  • HasComments::commentsQuery() — returns only top-level comments when
    threading is enabled, and eager-loads replies down to the configured
    max_depth to avoid N+1 queries.
  • SaveComment — accepts an optional parentId so replies persist with the
    correct parent.
  • Deleting a comment recursively deletes its replies.

UI

  • A Reply control on each comment opens an inline editor; saveReply()
    persists the reply via SaveComment. The control is hidden once a comment
    reaches max_depth, both in the UI and enforced server-side.
  • Replies render recursively, each indented beneath its parent.
  • Collapsible sub-threads — every comment with replies gets an accessible
    toggle (<button> with aria-expanded / aria-controls) that collapses or
    expands its sub-thread and shows a descendant count ("3 replies") when
    collapsed. Collapsing is handled client-side via Alpine, so no extra server
    round-trips and nested component state is preserved.
  • Facebook-style connector lines — replies are joined to their parent by a
    curved elbow connector (a continuous vertical trunk with a rounded branch into
    each reply's avatar). The trunk ends cleanly at the last reply. Drawn with pure
    CSS, including dark-mode colours.
  • Capped indentation — horizontal indent stops being added past depth 2 so
    deep threads stay readable on small screens; the connector line still renders
    at every level.
  • New English strings (replies_count, hide_replies) plus a reply key added
    across all bundled locales (ar, en, es, fr, nl, ro).

Configuration

A new commentions.threading config block:

'threading' => [
    'enabled'   => env('COMMENTIONS_THREADING_ENABLED', false),
    'max_depth' => (int) env('COMMENTIONS_THREADING_MAX_DEPTH', 3),
],

Threading is disabled by default. max_depth is the deepest reply level
allowed — top-level comments are depth 0, so the default of 3 permits replies
down to depth 3.

Backward compatibility

  • Non-breaking. With threading.enabled = false (the default), behaviour is
    identical to today — a flat comment list.
  • The migration is purely additive (new nullable column); existing comments
    become top-level comments (parent_id = null).
  • No changes to existing public method signatures (SaveComment's new argument
    is optional).

Testing

  • 16 feature tests in tests/Livewire/CommentThreadingTest.php cover: creating
    replies, depth calculation, top-level-only querying, reply-form behaviour,
    max_depth enforcement (UI + server), cascade deletion, repliesCount(),
    card-vs-flat rendering by depth, the collapse toggle markup and ARIA
    attributes, the reply-count label, connector rendering, and the indentation
    cap.

Adds threaded comment replies.

- New nullable self-referencing `parent_id` column (additive migration);
  the Comment model gains parent()/replies() relations and a depth() helper.
- HasComments::commentsQuery() returns only top-level comments when threading
  is enabled and eager-loads replies down to the configured max depth.
- A "Reply" Filament action on each comment opens an inline reply editor;
  saveReply() persists the reply with its parent_id via SaveComment. The
  reply control is hidden once a comment reaches the max depth.
- Replies render recursively beneath their parent with an indent.
- Controlled by a `commentions.threading` config block (disabled by default,
  default max_depth 3).
- Deleting a comment recursively deletes its replies (and their files).
- Add Facebook-style thread connector lines for nested replies
- Implement collapsible replies with accessible toggle showing
  descendant count
- Cap horizontal indentation at depth 2 to prevent layout issues on
  small screens
- Add `repliesCount()` method to recursively count all descendant
  comments
- Update CSS for thread lines with dark mode support
- Add English translations for replies UI
@ItsMalikJones ItsMalikJones self-assigned this May 18, 2026

@lisa-fehr lisa-fehr left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) may be ignored or added later - but other comments will need to be addressed.

Comment on lines +20 to +21
'replies_count' => '{0} No replies|{1} :count reply|[2,*] :count replies',
'hide_replies' => 'Hide replies',

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result

Missing replies_count and hide_replies in other language files. It is using English for the other languages.

Expectation

Add the missing translations

@@ -0,0 +1,28 @@
<?php

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result:

Missing README instructions for adding comment threads

Expectation:

  1. Add a README section that explains how to implement the comment threads IE: Set COMMENTIONS_THREADING_ENABLED=true.

  2. (nit) Add a generic Upgrading README section. IE: Upgrading > Rerun: php artisan vendor:publish --tag="commentions-migrations" then php artisan migrate. Or register an install command to rerun setup.

.mention-item:hover {
@apply comm:bg-gray-100;
}

@lisa-fehr lisa-fehr Jul 13, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit)

Result:

I didn't see the new styles when enabling this on an existing package install.

Expectation

  • Add generic Upgrade instructions to the README. IE: Run: php artisan vendor:publish --tag=filament-assets --force (or similar) then php artisan optimize:clear.
  • Add to install instructions IE: Run: php artisan filament:assets or add an install command

Comment thread src/HasComments.php
$paths = [];
$prefix = 'replies';

for ($level = 1; $level <= $maxDepth; $level++) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit)

Result

  1. No eager loading added to the query if depth is 0 and COMMENTIONS_THREADING_ENABLED=true (unexpected scenario)

    • When COMMENTIONS_THREADING_MAX_DEPTH=0
    • Then $paths=[]
    • When Model::preventLazyLoading(); is used
    • Then it throws a lazy loading warning
  2. Does not eager load all children when env depth has been reduced after comments were saved.

Expectation

Add a note with the README instructions to set depth to 1+ and not to reduce depth once there are comments.

x-bind:class="expanded ? '' : 'comm:-rotate-90'"
/>
<span x-show="expanded">{{ __('commentions::comments.hide_replies') }}</span>
<span x-show="!expanded" x-cloak>{{ trans_choice('commentions::comments.replies_count', $comment->repliesCount(), ['count' => $comment->repliesCount()]) }}</span>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) repliesCount() is called 4 times. Could improve this by saving it to a field.

expect($reply->replies()->count())->toBe(0);
});

test('deleting a comment deletes its nested replies', function () {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(nit) Best practice is to continue showing the replies and leave a 'deleted' stub for the removed parent comment.

Comment thread src/Livewire/Comment.php
* the thread line still renders, but no extra padding is added so deep
* threads don't compound horizontally on small screens.
*/
public const INDENT_CAP_DEPTH = 2;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Result

I added COMMENTIONS_THREADING_MAX_DEPTH=10 and it continued to indent a large amount from the left after '2'. In addition, it pushed the comments out of the available space when using a modal.

The style came from a different place in comment.blade.php@line 8: comm:pl-6.

'comm:relative comm:gap-x-3 comm:py-2 comm:pl-6' => $depth > 0

Expectation

Correct the line of code above to follow the same rule as shouldIndentReplies() which uses COMMENTIONS_THREADING_MAX_DEPTH.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add ability to nest comments

2 participants