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:
+190
-13
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user