feat: comment pagination, Matrix integration, Synapse mention resolution

Comment pagination:
- CommentModel: add getCommentCount(), paginated getCommentsByTicketId()
  with getThreadedCommentsPaged() for threading + LIMIT/OFFSET
- TicketController: load first 50 root comments + total count on page load
- api/get_comments.php: new AJAX endpoint for Load More (index.php routed)
- TicketView: Load More button + buildCommentEl() JS renderer for AJAX comments;
  passes totalComments/commentOffset/isAdmin to window.ticketData

Matrix integration:
- NotificationHelper: add sendStatusChangeNotification(), sendCommentNotification(),
  sendMentionNotification(), sendAssignmentNotification() alongside existing
  sendTicketNotification(); internal fire() helper replaces duplicated cURL logic
- SynapseHelper: new helper that resolves SSO usernames → Matrix IDs by querying
  Synapse Admin REST API directly (no caching, no stale data)
- config.php: add SYNAPSE_ADMIN_URL, SYNAPSE_ADMIN_TOKEN, MATRIX_NOTIFY_COMMENTS,
  MATRIX_NOTIFY_ASSIGNMENTS config keys (all from .env)
- api/update_ticket.php: fire status-change notification after successful save
- api/add_comment.php: resolve @mentioned usernames via SynapseHelper and fire
  mention notification; fire general comment notification when MATRIX_NOTIFY_COMMENTS=1
- api/assign_ticket.php: fire assignment notification (resolves assignee via Synapse)
  when MATRIX_NOTIFY_ASSIGNMENTS=1

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-29 21:34:16 -04:00
parent cc3f667d4c
commit c8181e8076
11 changed files with 645 additions and 57 deletions
+190 -13
View File
@@ -10,12 +10,13 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
$nonce = SecurityHeadersMiddleware::getNonce();
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
$activeNav = 'dashboard';
$pageStyles = ['/assets/css/ticket.css?v=20260327'];
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
$pageScripts = [
'/assets/js/markdown.js?v=20260327',
'/assets/js/ticket.js?v=20260327',
'/assets/js/keyboard-shortcuts.js?v=20260327',
'/assets/js/settings.js?v=20260327',
"/assets/js/markdown.js?v={$_v}",
"/assets/js/ticket.js?v={$_v}",
"/assets/js/keyboard-shortcuts.js?v={$_v}",
"/assets/js/settings.js?v={$_v}",
];
// Helper functions
@@ -77,16 +78,25 @@ $json_status = json_encode($ticket['status'], JSON_HEX_TAG);
$json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
$json_type = json_encode($ticket['type'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_updated_at = json_encode($ticket['updated_at'], JSON_HEX_TAG);
$json_total_comments = json_encode((int)$totalComments, JSON_HEX_TAG);
$json_comment_page = json_encode((int)$commentPageSize, JSON_HEX_TAG);
$json_current_uid = json_encode((int)($currentUser['user_id'] ?? 0), JSON_HEX_TAG);
$json_is_admin = json_encode(!empty($currentUser['is_admin']), JSON_HEX_TAG);
$pageInlineScript = <<<JS
window.ticketData = {
ticket_id: {$json_ticket_id},
title: {$json_title},
status: {$json_status},
priority: {$json_priority},
category: {$json_category},
type: {$json_type},
updated_at: {$json_updated_at},
ticket_id: {$json_ticket_id},
title: {$json_title},
status: {$json_status},
priority: {$json_priority},
category: {$json_category},
type: {$json_type},
updated_at: {$json_updated_at},
totalComments: {$json_total_comments},
commentOffset: {$json_comment_page},
commentPageSize:{$json_comment_page},
currentUserId: {$json_current_uid},
isAdmin: {$json_is_admin},
};
window.ticketData.id = window.ticketData.ticket_id;
if (window.lt) lt.keys.initDefaults();
@@ -450,6 +460,16 @@ include __DIR__ . '/layout_header.php';
}
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
?>
<?php if ($totalComments > $commentPageSize): ?>
<div id="loadMoreComments" class="lt-flex lt-flex-center lt-mt-md">
<button type="button" id="loadMoreBtn" class="lt-btn lt-btn-ghost lt-btn-sm">
Load more comments
<span class="lt-text-muted lt-text-xs" id="loadMoreCount">
(<?= (int)$totalComments - count($comments) ?> remaining)
</span>
</button>
</div>
<?php endif ?>
<?php endif ?>
</div>
</div>
@@ -809,6 +829,163 @@ document.addEventListener('DOMContentLoaded', function () {
}
});
// Load more comments
var loadMoreBtn = document.getElementById('loadMoreBtn');
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
var td = window.ticketData;
loadMoreBtn.disabled = true;
loadMoreBtn.textContent = 'Loading\u2026';
var url = '/api/get_comments.php?ticket_id=' + td.ticket_id +
'&offset=' + td.commentOffset +
'&limit=' + td.commentPageSize;
lt.api.get(url).then(function (data) {
if (!data.success) {
lt.toast.error('Failed to load comments: ' + (data.error || 'Unknown error'));
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
return;
}
var list = document.getElementById('commentsList');
var wrap = document.getElementById('loadMoreComments');
data.comments.forEach(function (c) {
list.insertBefore(buildCommentEl(c, td.currentUserId, td.isAdmin), wrap);
});
td.commentOffset += data.comments.length;
var remaining = td.totalComments - td.commentOffset;
if (data.has_more && remaining > 0) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments <span class="lt-text-muted lt-text-xs">(' + remaining + ' remaining)</span>';
} else {
wrap.remove();
}
// Re-render markdown in newly added comments
if (typeof parseMarkdown === 'function') {
list.querySelectorAll('.comment-text[data-markdown]').forEach(function (el) {
if (!el.dataset.rendered) {
el.innerHTML = parseMarkdown(el.textContent);
el.dataset.rendered = '1';
}
});
}
}).catch(function (err) {
lt.toast.error('Failed to load comments');
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = 'Load more comments';
});
});
}
/**
* Build a comment DOM element from a comment object returned by the API.
* Mirrors the PHP renderComment() output for root-level comments and replies.
*/
function buildCommentEl(c, currentUserId, isAdmin) {
var displayName = c.display_name_formatted || c.display_name || c.user_name || 'Unknown User';
var commentId = c.comment_id;
var isOwner = (parseInt(c.user_id, 10) === parseInt(currentUserId, 10));
var canModify = isOwner || isAdmin;
var mdEnabled = c.markdown_enabled == 1 || c.markdown_enabled === true;
var depth = parseInt(c.thread_depth, 10) || 0;
var parentId = c.parent_comment_id || null;
var depthClass = 'thread-depth-' + Math.min(depth, 3);
var threadClass = parentId ? 'comment-reply' : 'comment-root';
// Avatar initials
var words = displayName.trim().split(/\s+/).filter(Boolean);
var initials = words.slice(0, 2).map(function (w) { return w[0].toUpperCase(); }).join('');
// Avatar color (same modulo logic as PHP: crc32 mod 4)
var avatarColors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
var hash = 0;
for (var i = 0; i < displayName.length; i++) {
hash = ((hash << 5) - hash + displayName.charCodeAt(i)) | 0;
}
var avatarColor = avatarColors[Math.abs(hash) % 4];
// Format date
var dateStr = c.created_at || '';
try {
var d = new Date(c.created_at);
if (!isNaN(d)) {
dateStr = d.toLocaleString('en-US', { month:'short', day:'2-digit', year:'numeric', hour:'2-digit', minute:'2-digit', hour12:false }).replace(',', '');
}
} catch (e) {}
// Comment text
var rawText = c.comment_text || '';
var commentText;
if (mdEnabled) {
commentText = typeof parseMarkdown === 'function' ? parseMarkdown(rawText) : lt.escHtml(rawText);
} else {
var highlighted = lt.escHtml(rawText).replace(/\n/g, '<br>');
commentText = typeof highlightMentions === 'function' ? highlightMentions(highlighted) : highlighted;
}
var escapedRaw = lt.escHtml(rawText);
var editedHtml = c.updated_at ? '<span class="comment-edited lt-text-xs lt-text-muted">(edited)</span>' : '';
var userId = parseInt(c.user_id, 10) || 0;
var replyBtn = depth < 3 ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
' data-action="reply-comment" data-comment-id="' + commentId + '" data-user="' + lt.escHtml(displayName) + '"' +
' aria-label="Reply to comment">Reply</button>' : '';
var modBtns = canModify ?
'<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
' data-action="edit-comment" data-comment-id="' + commentId + '" aria-label="Edit comment">Edit</button>' +
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
' data-action="delete-comment" data-comment-id="' + commentId + '" aria-label="Delete comment">Del</button>' : '';
var threadLine = parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
var avatarImg = userId > 0 ?
'<img src="/api/user_avatar.php?user_id=' + userId + '" alt="" class="lt-avatar-img" onerror="this.style.display=\'none\'">' : '';
var div = document.createElement('div');
div.className = 'comment ' + depthClass + ' ' + threadClass;
div.dataset.commentId = commentId;
div.dataset.markdownEnabled = mdEnabled ? '1' : '0';
div.dataset.threadDepth = depth;
div.dataset.parentId = parentId || '';
div.innerHTML =
threadLine +
'<div class="comment-content">' +
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
'<div class="lt-avatar lt-avatar--xs ' + avatarColor + '" aria-hidden="true">' +
avatarImg +
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
'</div>' +
'<span class="comment-user lt-text-amber">' + lt.escHtml(displayName) + '</span>' +
'<span class="comment-date lt-text-xs lt-text-muted">' +
'<span class="ts-cell" data-ts="' + lt.escHtml(c.created_at || '') + '">' + lt.escHtml(dateStr) + '</span>' +
editedHtml +
'</span>' +
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
'</div>' +
'<div class="comment-text" id="comment-text-' + commentId + '"' + (mdEnabled ? ' data-markdown data-rendered="1"' : '') + '>' +
commentText +
'</div>' +
'<textarea class="lt-input lt-textarea comment-edit-raw" id="comment-raw-' + commentId + '" style="display:none" aria-hidden="true">' +
escapedRaw +
'</textarea>' +
'</div>';
// Append replies if any (threaded)
if (c.replies && c.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
c.replies.forEach(function (r) {
repliesDiv.appendChild(buildCommentEl(r, currentUserId, isAdmin));
});
div.appendChild(repliesDiv);
}
return div;
}
});
</script>