[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