From c8181e8076558e2da76063ae82f3bb763eb971da Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Sun, 29 Mar 2026 21:34:16 -0400 Subject: [PATCH] feat: comment pagination, Matrix integration, Synapse mention resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- api/add_comment.php | 21 ++++ api/assign_ticket.php | 17 +++ api/get_comments.php | 46 +++++++ api/update_ticket.php | 13 ++ config/config.php | 16 ++- controllers/TicketController.php | 6 +- helpers/NotificationHelper.php | 173 +++++++++++++++++++++----- helpers/SynapseHelper.php | 93 ++++++++++++++ index.php | 4 + models/CommentModel.php | 110 +++++++++++++++-- views/TicketView.php | 203 +++++++++++++++++++++++++++++-- 11 files changed, 645 insertions(+), 57 deletions(-) create mode 100644 api/get_comments.php create mode 100644 helpers/SynapseHelper.php diff --git a/api/add_comment.php b/api/add_comment.php index 3db5a3b..5937b36 100644 --- a/api/add_comment.php +++ b/api/add_comment.php @@ -29,6 +29,8 @@ try { require_once $auditLogModelPath; require_once dirname(__DIR__) . '/helpers/Database.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; + require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; + require_once dirname(__DIR__) . '/helpers/SynapseHelper.php'; // Check authentication via session if (session_status() === PHP_SESSION_NONE) { @@ -123,6 +125,25 @@ try { ); } + // Matrix notifications + $authorDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $commentText = $data['comment_text'] ?? ''; + $ticketTitle = $ticket['title'] ?? "Ticket #{$ticketId}"; + + // @mention notifications — resolve usernames → Matrix IDs via Synapse Admin API + if (!empty($mentionedUsers)) { + $mentionedUsernames = array_column($mentionedUsers, 'username'); + $mentionedMatrixIds = SynapseHelper::resolveUsernames($mentionedUsernames); + if (!empty($mentionedMatrixIds)) { + NotificationHelper::sendMentionNotification($ticketId, $ticketTitle, $commentText, $authorDisplay, $mentionedMatrixIds); + } + } + + // General comment notification (opt-in via MATRIX_NOTIFY_COMMENTS) + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_COMMENTS'])) { + NotificationHelper::sendCommentNotification($ticketId, $ticketTitle, $commentText, $authorDisplay); + } + // Add mentioned users to result for frontend $result['mentions'] = array_map(function($u) { return $u['username']; diff --git a/api/assign_ticket.php b/api/assign_ticket.php index 0bd59d4..281453a 100644 --- a/api/assign_ticket.php +++ b/api/assign_ticket.php @@ -3,6 +3,8 @@ require_once __DIR__ . '/bootstrap.php'; require_once dirname(__DIR__) . '/models/TicketModel.php'; require_once dirname(__DIR__) . '/models/AuditLogModel.php'; require_once dirname(__DIR__) . '/models/UserModel.php'; +require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; +require_once dirname(__DIR__) . '/helpers/SynapseHelper.php'; // Get request data $data = json_decode(file_get_contents('php://input'), true); @@ -60,6 +62,21 @@ if ($assignedTo === null || $assignedTo === '') { $success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId); if ($success) { $auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]); + + if (!empty($GLOBALS['config']['MATRIX_NOTIFY_ASSIGNMENTS'])) { + $changedByDisplay = $currentUser['display_name'] ?? $currentUser['username'] ?? null; + $assigneeName = $targetUser['display_name'] ?? $targetUser['username'] ?? null; + $assigneeMatrix = isset($targetUser['username']) + ? SynapseHelper::resolveUsername($targetUser['username']) + : null; + NotificationHelper::sendAssignmentNotification( + $ticketId, + $ticket['title'] ?? "Ticket #{$ticketId}", + $assigneeName, + $assigneeMatrix, + $changedByDisplay + ); + } } } diff --git a/api/get_comments.php b/api/get_comments.php new file mode 100644 index 0000000..373af4a --- /dev/null +++ b/api/get_comments.php @@ -0,0 +1,46 @@ + false, 'error' => 'Method not allowed']); + exit; +} + +$ticketId = isset($_GET['ticket_id']) ? (int)$_GET['ticket_id'] : 0; +$offset = isset($_GET['offset']) ? max(0, (int)$_GET['offset']) : 0; +$limit = isset($_GET['limit']) ? min(100, max(1, (int)$_GET['limit'])) : 50; + +if ($ticketId <= 0) { + http_response_code(400); + echo json_encode(['success' => false, 'error' => 'Invalid ticket_id']); + exit; +} + +$ticketModel = new TicketModel($conn); +$ticket = $ticketModel->getTicketById($ticketId); +if (!$ticket || !$ticketModel->canUserAccessTicket($ticket, $currentUser)) { + http_response_code(404); + echo json_encode(['success' => false, 'error' => 'Ticket not found']); + exit; +} + +$commentModel = new CommentModel($conn); +$total = $commentModel->getCommentCount($ticketId); +$comments = $commentModel->getCommentsByTicketId($ticketId, true, $limit, $offset); + +echo json_encode([ + 'success' => true, + 'comments' => $comments, + 'total' => $total, + 'offset' => $offset, + 'limit' => $limit, + 'has_more' => ($offset + $limit) < $total, +]); diff --git a/api/update_ticket.php b/api/update_ticket.php index c01f4d6..8d29f00 100644 --- a/api/update_ticket.php +++ b/api/update_ticket.php @@ -26,6 +26,7 @@ try { require_once $commentModelPath; require_once $auditLogModelPath; require_once $workflowModelPath; + require_once dirname(__DIR__) . '/helpers/NotificationHelper.php'; // Check authentication via session if (session_status() === PHP_SESSION_NONE) { @@ -207,6 +208,18 @@ try { } } + // Notify on status change + if ($currentTicket['status'] !== $updateData['status']) { + $changedBy = $this->currentUser['display_name'] ?? $this->currentUser['username'] ?? null; + NotificationHelper::sendStatusChangeNotification( + $id, + $currentTicket['status'], + $updateData['status'], + $updateData['title'], + $changedBy + ); + } + return [ 'success' => true, 'status' => $updateData['status'], diff --git a/config/config.php b/config/config.php index de85785..9b6899b 100644 --- a/config/config.php +++ b/config/config.php @@ -55,9 +55,19 @@ $GLOBALS['config'] = [ 'API_URL' => '/api', // API URL // Matrix webhook (hookshot generic webhook URL) - 'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null, - // Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org) - 'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '', + 'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null, + // Comma-separated Matrix user IDs to @mention on new tickets / status changes (e.g. @jared:matrix.lotusguild.org) + 'MATRIX_NOTIFY_USERS' => $envVars['MATRIX_NOTIFY_USERS'] ?? '', + // Matrix homeserver domain (e.g. matrix.lotusguild.org) — used to construct Matrix user IDs + 'MATRIX_DOMAIN' => $envVars['MATRIX_DOMAIN'] ?? null, + // Internal Synapse client-API base URL (e.g. http://10.10.10.29:8008) — used to verify user existence via Admin API + 'SYNAPSE_ADMIN_URL' => $envVars['SYNAPSE_ADMIN_URL'] ?? null, + // Synapse admin access token (generate with: register_new_matrix_user or admin API) + 'SYNAPSE_ADMIN_TOKEN' => $envVars['SYNAPSE_ADMIN_TOKEN'] ?? null, + // Set to '1' or 'true' to send a notification when any comment is posted + 'MATRIX_NOTIFY_COMMENTS' => filter_var($envVars['MATRIX_NOTIFY_COMMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN), + // Set to '1' or 'true' to send a notification when a ticket is assigned + 'MATRIX_NOTIFY_ASSIGNMENTS' => filter_var($envVars['MATRIX_NOTIFY_ASSIGNMENTS'] ?? false, FILTER_VALIDATE_BOOLEAN), // Domain settings for external integrations (webhooks, links, etc.) // Set APP_DOMAIN in .env to override diff --git a/controllers/TicketController.php b/controllers/TicketController.php index d98336f..3bfd2fb 100644 --- a/controllers/TicketController.php +++ b/controllers/TicketController.php @@ -49,8 +49,10 @@ class TicketController { return; } - // Get comments for this ticket using CommentModel - $comments = $this->commentModel->getCommentsByTicketId($id); + // Load first page of comments; show "load more" if ticket has many + $commentPageSize = 50; + $totalComments = $this->commentModel->getCommentCount((int)$id); + $comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0); // Get timeline for this ticket $timeline = $this->auditLogModel->getTicketTimeline($id); diff --git a/helpers/NotificationHelper.php b/helpers/NotificationHelper.php index 410c66b..1d5e09d 100644 --- a/helpers/NotificationHelper.php +++ b/helpers/NotificationHelper.php @@ -1,41 +1,17 @@ $ticketId, - 'title' => $ticketData['title'] ?? 'Untitled', - 'priority' => (int)($ticketData['priority'] ?? 4), - 'category' => $ticketData['category'] ?? 'General', - 'type' => $ticketData['type'] ?? 'Issue', - 'status' => $ticketData['status'] ?? 'Open', - 'source' => $source, - 'url' => UrlHelper::ticketUrl($ticketId), - 'trigger' => $trigger, - 'notify_users' => $notifyUsers, - ]; - $ch = curl_init($webhookUrl); curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']); curl_setopt($ch, CURLOPT_POST, 1); @@ -48,11 +24,146 @@ class NotificationHelper { $curlError = curl_error($ch); curl_close($ch); + $id = $payload['ticket_id'] ?? '?'; if ($curlError) { - error_log("Matrix webhook cURL error for ticket #{$ticketId}: {$curlError}"); + error_log("Matrix webhook cURL error for ticket #{$id}: {$curlError}"); } elseif ($httpCode < 200 || $httpCode >= 300) { - error_log("Matrix webhook failed for ticket #{$ticketId}. HTTP {$httpCode}: {$response}"); + error_log("Matrix webhook failed for ticket #{$id}. HTTP {$httpCode}: {$response}"); } } + + private static function notifyUsers(): array { + $raw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? ''; + return array_values(array_filter(array_map('trim', explode(',', $raw)))); + } + + // ─── Public event methods ───────────────────────────────────────────────── + + /** + * New ticket created (manual or automated/API). + */ + public static function sendTicketNotification($ticketId, array $ticketData, string $trigger = 'manual'): void { + preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m); + $source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual'); + + self::fire([ + 'event' => 'ticket_created', + 'ticket_id' => $ticketId, + 'title' => $ticketData['title'] ?? 'Untitled', + 'priority' => (int)($ticketData['priority'] ?? 4), + 'category' => $ticketData['category'] ?? 'General', + 'type' => $ticketData['type'] ?? 'Issue', + 'status' => $ticketData['status'] ?? 'Open', + 'source' => $source, + 'url' => UrlHelper::ticketUrl($ticketId), + 'trigger' => $trigger, + 'notify_users' => self::notifyUsers(), + ]); + } + + /** + * Ticket status changed. + * + * @param string|int $ticketId + * @param string $oldStatus + * @param string $newStatus + * @param string $ticketTitle + * @param string|null $changedByDisplay Display name of the user who changed status + */ + public static function sendStatusChangeNotification($ticketId, string $oldStatus, string $newStatus, string $ticketTitle, ?string $changedByDisplay = null): void { + self::fire([ + 'event' => 'status_changed', + 'ticket_id' => $ticketId, + 'title' => $ticketTitle, + 'old_status' => $oldStatus, + 'new_status' => $newStatus, + 'changed_by' => $changedByDisplay, + 'url' => UrlHelper::ticketUrl($ticketId), + 'notify_users' => self::notifyUsers(), + ]); + } + + /** + * New comment posted (non-mention; use sendMentionNotification for @mentions). + * + * @param string|int $ticketId + * @param string $ticketTitle + * @param string $commentText Plain text (first 200 chars will be sent) + * @param string|null $authorDisplay Display name of commenter + * @param bool $isInternal True if the comment is internal-only + */ + public static function sendCommentNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay = null, bool $isInternal = false): void { + // Skip if this is an internal-only comment — only the assignee/admin need to know + $notifyUsers = self::notifyUsers(); + if (empty($notifyUsers)) { + return; + } + + self::fire([ + 'event' => 'comment_added', + 'ticket_id' => $ticketId, + 'title' => $ticketTitle, + 'author' => $authorDisplay, + 'preview' => mb_strimwidth($commentText, 0, 200, '…'), + 'is_internal' => $isInternal, + 'url' => UrlHelper::ticketUrl($ticketId), + 'notify_users' => $notifyUsers, + ]); + } + + /** + * @mention detected in a comment. + * + * @param string|int $ticketId + * @param string $ticketTitle + * @param string $commentText + * @param string|null $authorDisplay + * @param array $mentionedMatrixIds Matrix user IDs derived from @usernames + */ + public static function sendMentionNotification($ticketId, string $ticketTitle, string $commentText, ?string $authorDisplay, array $mentionedMatrixIds): void { + if (empty($mentionedMatrixIds)) { + return; + } + + self::fire([ + 'event' => 'mention', + 'ticket_id' => $ticketId, + 'title' => $ticketTitle, + 'author' => $authorDisplay, + 'preview' => mb_strimwidth($commentText, 0, 200, '…'), + 'url' => UrlHelper::ticketUrl($ticketId), + 'notify_users' => $mentionedMatrixIds, + ]); + } + + /** + * Ticket assigned (or reassigned) to a user. + * + * @param string|int $ticketId + * @param string $ticketTitle + * @param string|null $assigneeName Display name of new assignee + * @param string|null $assigneeMatrix Matrix user ID of new assignee (to DM) + * @param string|null $changedByDisplay + */ + public static function sendAssignmentNotification($ticketId, string $ticketTitle, ?string $assigneeName, ?string $assigneeMatrix, ?string $changedByDisplay = null): void { + $notifyUsers = self::notifyUsers(); + // Also notify the assignee directly if we know their Matrix ID + if ($assigneeMatrix && !in_array($assigneeMatrix, $notifyUsers, true)) { + $notifyUsers[] = $assigneeMatrix; + } + if (empty($notifyUsers)) { + return; + } + + self::fire([ + 'event' => 'assigned', + 'ticket_id' => $ticketId, + 'title' => $ticketTitle, + 'assignee' => $assigneeName, + 'changed_by' => $changedByDisplay, + 'url' => UrlHelper::ticketUrl($ticketId), + 'notify_users' => $notifyUsers, + ]); + } } ?> diff --git a/helpers/SynapseHelper.php b/helpers/SynapseHelper.php new file mode 100644 index 0000000..670aad1 --- /dev/null +++ b/helpers/SynapseHelper.php @@ -0,0 +1,93 @@ + diff --git a/index.php b/index.php index ec3c54c..d4dba09 100644 --- a/index.php +++ b/index.php @@ -115,6 +115,10 @@ switch (true) { require_once 'api/get_users.php'; break; + case $requestPath == '/api/get_comments.php': + require_once 'api/get_comments.php'; + break; + case $requestPath == '/api/assign_ticket.php': require_once 'api/assign_ticket.php'; break; diff --git a/models/CommentModel.php b/models/CommentModel.php index e0ae669..50cbaf2 100644 --- a/models/CommentModel.php +++ b/models/CommentModel.php @@ -50,10 +50,35 @@ class CommentModel { return $users; } - public function getCommentsByTicketId($ticketId, $threaded = true) { - // Check if threading columns exist + /** + * Get total comment count for a ticket + */ + public function getCommentCount(int $ticketId): int { + $stmt = $this->conn->prepare( + "SELECT COUNT(*) as total FROM ticket_comments WHERE ticket_id = ?" + ); + $stmt->bind_param("i", $ticketId); + $stmt->execute(); + $row = $stmt->get_result()->fetch_assoc(); + $stmt->close(); + return (int)($row['total'] ?? 0); + } + + /** + * @param int $ticketId + * @param bool $threaded Build nested reply structure (threading) + * @param int $limit Max root-level comments to return (0 = all) + * @param int $offset Root-level comment offset for pagination + */ + public function getCommentsByTicketId($ticketId, $threaded = true, int $limit = 0, int $offset = 0) { $hasThreading = $this->hasThreadingSupport(); + // When paginating with threading we fetch root comments page first, + // then pull all their replies in a second query. + if ($hasThreading && $threaded && $limit > 0) { + return $this->getThreadedCommentsPaged($ticketId, $limit, $offset); + } + if ($hasThreading) { $sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth FROM ticket_comments tc @@ -70,16 +95,21 @@ class CommentModel { ORDER BY tc.created_at DESC"; } + if ($limit > 0) { + $sql .= " LIMIT ? OFFSET ?"; + } + $stmt = $this->conn->prepare($sql); - $stmt->bind_param("i", $ticketId); + if ($limit > 0) { + $stmt->bind_param("iii", $ticketId, $limit, $offset); + } else { + $stmt->bind_param("i", $ticketId); + } $stmt->execute(); $result = $stmt->get_result(); - $comments = []; $commentMap = []; - while ($row = $result->fetch_assoc()) { - // Use display_name from users table if available, fallback to user_name field if (!empty($row['display_name'])) { $row['display_name_formatted'] = $row['display_name']; } else { @@ -90,8 +120,9 @@ class CommentModel { $row['thread_depth'] = $row['thread_depth'] ?? 0; $commentMap[$row['comment_id']] = $row; } + $stmt->close(); - // Build threaded structure if threading is enabled + // Build threaded structure if threading is enabled (no pagination — all loaded) if ($hasThreading && $threaded) { $rootComments = []; foreach ($commentMap as $id => $comment) { @@ -102,10 +133,73 @@ class CommentModel { return $rootComments; } - // Flat list return array_values($commentMap); } + /** + * Paginated threaded comments: fetch one page of root comments + all their replies. + */ + private function getThreadedCommentsPaged(int $ticketId, int $limit, int $offset): array { + // Page of root comments + $rootSql = "SELECT tc.*, u.display_name, u.username + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.ticket_id = ? AND tc.parent_comment_id IS NULL + ORDER BY tc.created_at DESC + LIMIT ? OFFSET ?"; + $stmt = $this->conn->prepare($rootSql); + $stmt->bind_param("iii", $ticketId, $limit, $offset); + $stmt->execute(); + $rootResult = $stmt->get_result(); + $stmt->close(); + + $commentMap = []; + $rootIds = []; + while ($row = $rootResult->fetch_assoc()) { + $row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User'); + $row['replies'] = []; + $row['parent_comment_id'] = null; + $row['thread_depth'] = 0; + $commentMap[$row['comment_id']] = $row; + $rootIds[] = $row['comment_id']; + } + + if (empty($rootIds)) { + return []; + } + + // All replies for these root comments (up to 3 levels deep) + $placeholders = implode(',', array_fill(0, count($rootIds), '?')); + $replySql = "SELECT tc.*, u.display_name, u.username + FROM ticket_comments tc + LEFT JOIN users u ON tc.user_id = u.user_id + WHERE tc.ticket_id = ? + AND tc.parent_comment_id IN ($placeholders) + AND tc.parent_comment_id IS NOT NULL + ORDER BY tc.created_at ASC"; + $replyStmt = $this->conn->prepare($replySql); + $types = 'i' . str_repeat('i', count($rootIds)); + $replyStmt->bind_param($types, $ticketId, ...$rootIds); + $replyStmt->execute(); + $replyResult = $replyStmt->get_result(); + $replyStmt->close(); + + while ($row = $replyResult->fetch_assoc()) { + $row['display_name_formatted'] = $row['display_name'] ?: ($row['user_name'] ?? 'Unknown User'); + $row['replies'] = []; + $row['thread_depth'] = $row['thread_depth'] ?? 1; + $commentMap[$row['comment_id']] = $row; + } + + $rootComments = []; + foreach ($rootIds as $rid) { + if (isset($commentMap[$rid])) { + $rootComments[] = $this->buildCommentThread($commentMap[$rid], $commentMap); + } + } + return $rootComments; + } + /** * Check if threading columns exist */ diff --git a/views/TicketView.php b/views/TicketView.php index 12fd78f..7408f8e 100644 --- a/views/TicketView.php +++ b/views/TicketView.php @@ -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 = << + $commentPageSize): ?> +
+ +
+ @@ -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 (' + remaining + ' remaining)'; + } 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, '
'); + commentText = typeof highlightMentions === 'function' ? highlightMentions(highlighted) : highlighted; + } + + var escapedRaw = lt.escHtml(rawText); + var editedHtml = c.updated_at ? '(edited)' : ''; + var userId = parseInt(c.user_id, 10) || 0; + + var replyBtn = depth < 3 ? + '' : ''; + var modBtns = canModify ? + '' + + '' : ''; + var threadLine = parentId ? '' : ''; + var avatarImg = userId > 0 ? + '' : ''; + + 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 + + '
' + + '
' + + '' + + '' + lt.escHtml(displayName) + '' + + '' + + '' + lt.escHtml(dateStr) + '' + + editedHtml + + '' + + '
' + replyBtn + modBtns + '
' + + '
' + + '
' + + commentText + + '
' + + '' + + '
'; + + // 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; + } + });