[TikiWiki-commits] [Git][tikiwiki/tiki][master] [ENH][NEW] Collapsible and Resolvable Comment Threads

"luci \(@luciash\) via TikiWiki-cvs" <[email protected]>
Newsgroups gmane.comp.cms.tiki.cvs
Message-ID <69f2305acaf2f_3818f67877231@gitlab-sidekiq-low-urgency-cpu-bound-v2-75976c9f59-ktgr8.mail>

luci pushed to branch master at Tiki Wiki CMS Groupware / Tiki


Commits:
5adf94c1 by Moïse Nturubika at 2026-04-29T16:04:26+00:00
[ENH][NEW] Collapsible and Resolvable Comment Threads
---
* [FIX] Archive button color and resolved thread expansion

* make Version in list_inner.tpl translatable

* [FIX] Resolve the issue where a close button we appearing nested in comment section

* [ENH] Remove resolve option for comments without replies

* [ENH] Comments: Implement resolvable and collapsible comment threads

See merge request tikiwiki/tiki!10050

- - - - -


8 changed files:

- db/tiki.sql
- + installer/schema/20260416_add_is_resolved_to_comments_tiki.sql
- lib/comments/commentslib.php
- lib/core/Services/Comment/Controller.php
- lib/prefs/comments.php
- templates/admin/include_comments.tpl
- templates/comment/list.tpl
- templates/comment/list_inner.tpl


Changes:

=====================================
db/tiki.sql
=====================================
@@ -724,6 +724,7 @@ CREATE TABLE `tiki_comments` (
   `archived` char(1) default NULL,
   `approved` char(1) NOT NULL default 'y',
   `locked` char(1) NOT NULL default 'n',
+  `is_resolved` char(1) NOT NULL default 'n',
   PRIMARY KEY (`threadId`),
   KEY `title` (`title`(191)),
   KEY `data` (`data`(191)),


=====================================
installer/schema/20260416_add_is_resolved_to_comments_tiki.sql
=====================================
@@ -0,0 +1 @@
+ALTER TABLE `tiki_comments` ADD COLUMN `is_resolved` char(1) NOT NULL DEFAULT 'n' AFTER `locked`;


=====================================
lib/comments/commentslib.php
=====================================
@@ -2552,7 +2552,7 @@ class Comments extends TikiLib
             if ($tiki_p_admin_comments == 'y') {
                 $adminFields = ', tc1.`user_ip`';
             }
-            $query = "select tc1.`threadId`, tc1.`object`, tc1.`objectType`, tc1.`parentId`, tc1.`userName`, tc1.`commentDate`, tc1.`hits`, tc1.`type`, tc1.`points`, tc1.`votes`, tc1.`average`, tc1.`title`, tc1.`data`, tc1.`summary`, tc1.`smiley`, tc1.`message_id`, tc1.`in_reply_to`, tc1.`comment_rating`, tc1.`approved`, tc1.`locked`$adminFields  from `tiki_comments` as tc1
+            $query = "select tc1.`threadId`, tc1.`object`, tc1.`objectType`, tc1.`parentId`, tc1.`userName`, tc1.`commentDate`, tc1.`hits`, tc1.`type`, tc1.`points`, tc1.`votes`, tc1.`average`, tc1.`title`, tc1.`data`, tc1.`summary`, tc1.`smiley`, tc1.`message_id`, tc1.`in_reply_to`, tc1.`comment_rating`, tc1.`approved`, tc1.`locked`, tc1.`is_resolved`$adminFields  from `tiki_comments` as tc1
                 left outer join `tiki_comments` as tc2 on tc1.`in_reply_to` = tc2.`message_id`
                 and tc1.`parentId` = ?
                 and tc2.`parentId` = ?
@@ -3857,6 +3857,41 @@ class Comments extends TikiLib
         return false;
     }
 
+    /**
+     * Mark a top-level comment thread as resolved.
+     * Resolved threads are collapsed by default in the UI.
+     *
+     * @param int $threadId the comment/thread id
+     * @return bool|TikiDb_Pdo_Result
+     */
+    public function resolveThread($threadId)
+    {
+        if ($threadId > 0) {
+            return $this->table('tiki_comments')->update(
+                ['is_resolved' => 'y'],
+                ['threadId' => (int) $threadId]
+            );
+        }
+        return false;
+    }
+
+    /**
+     * Unresolve a previously resolved comment thread.
+     *
+     * @param int $threadId the comment/thread id
+     * @return bool|TikiDb_Pdo_Result
+     */
+    public function unresolveThread($threadId)
+    {
+        if ($threadId > 0) {
+            return $this->table('tiki_comments')->update(
+                ['is_resolved' => 'n'],
+                ['threadId' => (int) $threadId]
+            );
+        }
+        return false;
+    }
+
     /**
      * @return array
      */


=====================================
lib/core/Services/Comment/Controller.php
=====================================
@@ -69,6 +69,7 @@ class Services_Comment_Controller
             'allow_lock'        => $this->canLock($type, $objectId),
             'allow_unlock'      => $this->canUnlock($type, $objectId),
             'allow_archive'     => $this->canArchive($type, $objectId),
+            'allow_resolve'     => $this->canResolve($type, $objectId),
             'allow_moderate'    => $this->canModerate($type, $objectId),
             'allow_vote'        => $this->canVote($type, $objectId),
         ];
@@ -620,6 +621,39 @@ class Services_Comment_Controller
         ];
     }
 
+    public function action_resolve($input)
+    {
+        $threadId = $input->threadId->int();
+        $do = $input->do->alpha();
+        if (! $comment = $this->getCommentInfo($threadId)) {
+            throw new Services_Exception(tr('Comment not found.'), 404);
+        }
+
+        $type = $comment['objectType'];
+        $object = $comment['object'];
+
+        if (! $this->canResolve($type, $object)) {
+            throw new Services_Exception(tr('Permission denied.'), 403);
+        }
+
+        $status = 'DONE';
+        $commentslib = TikiLib::lib('comments');
+
+        if ($do == 'resolve') {
+            $commentslib->resolveThread($threadId);
+        } else {
+            $commentslib->unresolveThread($threadId);
+        }
+
+        return [
+            'threadId' => $threadId,
+            'type' => $type,
+            'objectId' => $object,
+            'status' => $status,
+            'do' => $do,
+        ];
+    }
+
     public function action_deliberation_item($input)
     {
         return [];
@@ -809,6 +843,19 @@ class Services_Comment_Controller
         return $perms->admin_comments;
     }
 
+    private function canResolve($type, $objectId)
+    {
+        global $prefs;
+
+        if ($prefs['comments_resolved_threads'] != 'y') {
+            return false;
+        }
+
+        $perms = $this->getApplicablePermissions($type, $objectId);
+
+        return $perms->admin_comments;
+    }
+
     private function canRemove($type, $objectId)
     {
         $perms = $this->getApplicablePermissions($type, $objectId);


=====================================
lib/prefs/comments.php
=====================================
@@ -38,6 +38,12 @@ function prefs_comments_list()
             'type' => 'flag',
             'default' => 'n',
         ],
+        'comments_resolved_threads' => [
+            'name' => tra('Resolvable comment threads'),
+            'description' => tra('Allow marking top-level comment threads as resolved. Resolved threads are collapsed by default.'),
+            'type' => 'flag',
+            'default' => 'n',
+        ],
         'comments_allow_correction' => [
             'name' => tr('Allow comments to be edited by their author'),
             'description' => tr('Allow a comment to be modified by its author after posting it, for clarifications, correction of errors, etc.'),


=====================================
templates/admin/include_comments.tpl
=====================================
@@ -17,6 +17,7 @@
             {preference name=feature_comments_post_as_anonymous}
             {preference name=comments_vote}
             {preference name=comments_archive}
+            {preference name=comments_resolved_threads}
             {preference name=comments_allow_correction}
             <div class="adminoptionboxchild" id="comments_allow_correction_childcontainer">
                 {preference name=comments_correction_timeout}


=====================================
templates/comment/list.tpl
=====================================
@@ -32,6 +32,107 @@
         <script type="text/javascript">
             $(function() {
                 $('#comment-container').applyColorbox();
+
+                {if $prefs.comments_resolved_threads eq 'y'}
+                    function initResolvedThreads() {
+                        var hash = window.location.hash;
+                        $('.comment-thread-wrapper[data-resolved="true"]').each(function() {
+                            var $wrapper = $(this);
+                            var $collapse = $wrapper.find('.collapse').first();
+                            if ($collapse.length) {
+                                var shouldExpand = false;
+                                if (hash && hash.match(/^#threadId=?\d+/)) {
+                                    var targetSelector = hash.replace('=', '');
+                                    if ($wrapper.is(targetSelector) || $wrapper.find(targetSelector).length > 0) {
+                                        shouldExpand = true;
+                                    }
+                                }
+                                
+                                try {
+                                    if (shouldExpand) {
+                                        $collapse.addClass('show');
+                                        $wrapper.find('.comment-resolved-header').attr('aria-expanded', 'true');
+                                        $wrapper.find('.comment-collapse-icon').addClass('comment-collapse-icon-open');
+                                        setTimeout(function() {
+                                            var targetId = hash.replace('=', '');
+                                            var $target = $(targetId);
+                                            if ($target.length) {
+                                                $('html, body').stop().animate({
+                                                    scrollTop: $target.offset().top - 150
+                                                }, 800);
+                                                $target.addClass('comment-highlight');
+                                                setTimeout(function() { $target.removeClass('comment-highlight'); }, 3000);
+                                            }
+                                        }, 700);
+                                    } else {
+                                        $collapse.removeClass('show');
+                                        $wrapper.find('.comment-resolved-header').attr('aria-expanded', 'false');
+                                        $wrapper.find('.comment-collapse-icon').removeClass('comment-collapse-icon-open');
+                                    }
+                                } catch (e) {
+                                    // Ignore errors during scroll/expand initialization
+                                }
+                            }
+                        });
+                    }
+                    initResolvedThreads();
+                    $(window).on('hashchange', function() {
+                        initResolvedThreads();
+                    });
+                    $(document).off('click.resolved').on('click.resolved', '.comment-resolved-header', function() {
+                        // Toggle handled by Bootstrap collapse
+                    });
+                    $(document).on('shown.bs.collapse', '.comment-thread-wrapper .collapse', function() {
+                        var $wrapper = $(this).closest('.comment-thread-wrapper');
+                        $wrapper.find('.comment-collapse-icon').addClass('comment-collapse-icon-open');
+                        $wrapper.find('.comment-resolved-header').attr('aria-expanded', 'true');
+                    });
+                    $(document).on('hidden.bs.collapse', '.comment-thread-wrapper .collapse', function() {
+                        var $wrapper = $(this).closest('.comment-thread-wrapper');
+                        $wrapper.find('.comment-collapse-icon').removeClass('comment-collapse-icon-open');
+                        $wrapper.find('.comment-resolved-header').attr('aria-expanded', 'false');
+                    });
+                    $(document).on('tiki.ajax.redraw', function() {
+                        initResolvedThreads();
+                    });
+
+                    $(document).on('click', '.resolve-direct', function(e) {
+                        e.preventDefault();
+                        e.stopImmediatePropagation();
+                        var $btn = $(this);
+                        var url = $btn.data('url');
+                        
+                        $btn.prop('disabled', true).find('i, .tikiicon').addClass('fa-spin-fast');
+                        
+                        $.post(url, function(data) {
+                            if (data.status === 'DONE') {
+                                var $container = $('#comments, #comment-container, .comment-container').filter(function() {
+                                    return typeof $(this).comment_load === 'function';
+                                }).first();
+
+                                if ($container.length) {
+                                    $container.comment_load($.service('comment', 'list', {
+                                        objectId: objectId,
+                                        type: objectType,
+                                        modal: 1
+                                    }));
+                                } else {
+                                    window.location.reload();
+                                }
+                            } else if (data.errors) {
+                                alert(data.errors.join("\n"));
+                                $btn.prop('disabled', false).find('i, .tikiicon').removeClass('fa-spin-fast');
+                                if (typeof $.tikiModal === 'function') $.tikiModal();
+                            }
+                        }, 'json').fail(function() {
+                            // Ensure the spinner is removed on error
+                            $btn.prop('disabled', false).find('i, .tikiicon').removeClass('fa-spin-fast');
+                            if (typeof $.tikiModal === 'function') $.tikiModal();
+                            // Optionally show a brief error
+                            if (typeof showMessage === 'function') showMessage(tr('An error occurred while processing the request'), 'error');
+                        });
+                    });
+                {/if}
             })
         </script>
     {else}
@@ -86,5 +187,50 @@
     <script type="text/javascript">
         var ajax_url = '{$base_url}';
         var objectId = '{$objectId|escape:'javascript'}';
+        var objectType = '{$type|escape:'javascript'}';
     </script>
+    
+    {if $prefs.comments_resolved_threads eq 'y'}
+    <style>
+        {* Hide modal chrome when loaded inside the comment container *}
+        #comments .modal-header, 
+        #comments .modal-footer, 
+        #comments .btn-close, 
+        #comments .modal-content > .btn.btn-link,
+        .comment-container .modal-header,
+        .comment-container .modal-footer,
+        .comment-container .btn-close,
+        .comment-container .modal-content > .btn.btn-link {
+            display: none !important;
+        }
+        
+        .comment-resolved-header {
+            cursor: pointer;
+            transition: all 0.2s ease-in-out;
+            border-left: 4px solid var(--bs-success) !important;
+        }
+        .comment-resolved-header:hover {
+            background-color: rgba(var(--bs-success-rgb), 0.05) !important;
+        }
+        .comment-collapse-icon {
+            transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
+            color: var(--bs-success);
+        }
+        .comment-collapse-icon-open {
+            transform: rotate(90deg);
+        }
+        .comment-resolved {
+            border-left: 2px solid var(--bs-success-bg-subtle);
+            padding-left: 1rem;
+            opacity: 0.85;
+        }
+        .comment-highlight {
+            animation: commentHighlightPulse 2s ease-in-out infinite;
+        }
+        @keyframes commentHighlightPulse {
+            0%, 100% { background-color: transparent; }
+            50% { background-color: rgba(var(--bs-warning-rgb), 0.15); }
+        }
+    </style>
+    {/if}
 {/block}


=====================================
templates/comment/list_inner.tpl
=====================================
@@ -1,6 +1,9 @@
 <ul class="list-unstyled">
     {foreach from=$comments item=comment}
-        <li class="d-flex comment mt-3 mb-4{if $comment.archived eq 'y'} archived{* well well-sm*}{/if} {if $allow_moderate}{if $comment.approved eq 'n'} pending bg-warning{elseif $comment.approved eq 'r'} rejected bg-danger{/if}{/if}{*{if ! $parentId && $prefs.feature_wiki_paragraph_formatting eq 'y'} inline{/if}*}" data-comment-thread-id="{$comment.threadId|escape}">
+        {* Determine if this is a top-level resolved thread *}
+        {assign var="is_top_level" value=(!$level || $level eq 0)}
+        {assign var="is_resolved_thread" value=($is_top_level && isset($comment.is_resolved) && $comment.is_resolved eq 'y' && $prefs.comments_resolved_threads eq 'y')}
+        <li class="d-flex comment mt-3 mb-4{if $comment.archived eq 'y'} archived{* well well-sm*}{/if} {if $allow_moderate}{if $comment.approved eq 'n'} pending bg-warning{elseif $comment.approved eq 'r'} rejected bg-danger{/if}{/if}{*{if ! $parentId && $prefs.feature_wiki_paragraph_formatting eq 'y'} inline{/if}*}{if $is_resolved_thread} comment-resolved comment-thread-wrapper{/if}" data-comment-thread-id="{$comment.threadId|escape}" id="threadId{$comment.threadId|escape}" {if $is_resolved_thread}data-resolved="true"{/if}>
             <div class="align-self-start me-3">
                 <span class="avatar">{$comment.userName|avatarize:'':'img/noavatar.png'}</span>
             </div>
@@ -16,6 +19,9 @@
                             </div>
                         {/if}
                         <div class="comment-info">
+                            {if $is_resolved_thread}
+                                <span class="badge bg-success me-2">{icon name="check"} {tr}Resolved{/tr}</span>
+                            {/if}
                             {tr _0=$comment.userName|userlink}%0{/tr}{if $prefs.comments_threshold_indent neq '0' && $level && $level gte $prefs.comments_threshold_indent}>{tr _0=$repliedTo.userName|userlink}%0{/tr}{/if} <small class="date">{tr _0=$comment.commentDate|tiki_short_datetime}%0{/tr}</small>
                             {if $prefs.comments_heading_links eq 'y' and  $prefs.comments_notitle eq 'y'}
                                 <button type="button" class="heading-link copy-comment-link tips btn btn-link p-0" title="|{tr}Click to copy the comment link{/tr}" aria-label="{tr}Heading link{/tr}" data-thread-id="{if $comment.threadId neq $comments_parentId}threadId{$comment.threadId}{/if}">{icon name="link" _class="me-1"}</button>
@@ -47,7 +53,14 @@
                                     <span class="label label-primary">{tr}Archived{/tr}</span>
                                     <a class="btn btn-info btn-sm" href="{service controller=comment action=archive do=unarchive threadId=$comment.threadId}">{tr}Unarchive{/tr}</a>
                                 {else}
-                                    <a class="btn btn-success btn-sm" href="{service controller=comment action=archive do=archive threadId=$comment.threadId}">{tr}Archive{/tr}</a>
+                                    <a class="btn btn-warning btn-sm" href="{service controller=comment action=archive do=archive threadId=$comment.threadId}">{tr}Archive{/tr}</a>
+                                {/if}
+                            {/if}
+                            {if $allow_resolve && $is_top_level}
+                                {if isset($comment.is_resolved) && $comment.is_resolved eq 'y'}
+                                    <button type="button" class="btn btn-outline-success btn-sm resolve-direct" data-url="{service controller=comment action=resolve do=unresolve threadId=$comment.threadId}" title="{tr}Mark as unresolved{/tr}">{icon name="undo"} {tr}Unresolve{/tr}</button>
+                                {elseif $comment.replies_info.numReplies gt 0}
+                                    <button type="button" class="btn btn-success btn-sm resolve-direct" data-url="{service controller=comment action=resolve do=resolve threadId=$comment.threadId}" title="{tr}Mark as resolved{/tr}">{icon name="check"} {tr}Resolve{/tr}</button>
                                 {/if}
                             {/if}
                         {/block}
@@ -90,9 +103,9 @@
                             {rating_result type="comment" id=$comment.threadId}
                         {/if}
                         {if !empty($comment.diffInfo)}
-                            <div class="{*well*}"><pre style="display: none;">{$comment.diffInfo|var_dump}</pre>
+                            <div class="{*well*}">
                                 <h4 class="btn btn-link" type="button" data-bs-toggle="collapse" data-bs-target=".version{$comment.diffInfo[0].version}" aria-expanded="false" aria-controls="collapseExample">
-                                    Version {$comment.diffInfo[0].version}
+                                    {tr}Version{/tr} {$comment.diffInfo[0].version}
                                     {icon name='history'}
                                 </h4>
                                 <div class="collapse table-responsive version{$comment.diffInfo[0].version}">
@@ -111,7 +124,21 @@
                 </div>{* End of comment-item *}
                 {if ! $level || $prefs.comments_threshold_indent eq '0' || $level lt $prefs.comments_threshold_indent}
                     {if $comment.replies_info.numReplies gt 0}
+                        {if $is_resolved_thread}
+                            <div class="comment-resolved-header d-flex align-items-center mt-3 mb-2 p-2 rounded bg-light border" role="button" data-bs-toggle="collapse" data-bs-target="#comment-thread-body-{$comment.threadId|escape}" aria-expanded="false" aria-controls="comment-thread-body-{$comment.threadId|escape}">
+                                <span class="text-muted small flex-grow-1">
+                                    {icon name="comments"} {tr}Replies{/tr}
+                                </span>
+                                <span class="comment-collapse-icon ms-2">{icon name="chevron-right"}</span>
+                            </div>
+                            <div class="collapse" id="comment-thread-body-{$comment.threadId|escape}">
+                        {/if}
+
                         {include file='comment/list_inner.tpl' comments=$comment.replies_info.replies count=$comment.replies_info.numReplies parentId=$comment.threadId level=(level) ? $level+1 : 0 repliedTo=$comment}
+
+                        {if $is_resolved_thread}
+                            </div>
+                        {/if}
                     {/if}
                 {/if}
             </div>{* End of flex-grow-1 ms-3 *}



View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/5adf94c109dbd3b7638839c3ff6c00f98e539e80

-- 
View it on GitLab: https://gitlab.com/tikiwiki/tiki/-/commit/5adf94c109dbd3b7638839c3ff6c00f98e539e80
You're receiving this email because of your account on gitlab.com. Manage all notifications: https://gitlab.com/-/profile/notifications | Help: https://gitlab.com/help

_______________________________________________
TikiWiki-cvs mailing list
[email protected]
https://lists.sourceforge.net/lists/listinfo/tikiwiki-cvs
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.