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:
@@ -29,6 +29,8 @@ try {
|
|||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once dirname(__DIR__) . '/helpers/Database.php';
|
require_once dirname(__DIR__) . '/helpers/Database.php';
|
||||||
require_once dirname(__DIR__) . '/models/TicketModel.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
|
// Check authentication via session
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
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
|
// Add mentioned users to result for frontend
|
||||||
$result['mentions'] = array_map(function($u) {
|
$result['mentions'] = array_map(function($u) {
|
||||||
return $u['username'];
|
return $u['username'];
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ require_once __DIR__ . '/bootstrap.php';
|
|||||||
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
require_once dirname(__DIR__) . '/models/AuditLogModel.php';
|
||||||
require_once dirname(__DIR__) . '/models/UserModel.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
|
// Get request data
|
||||||
$data = json_decode(file_get_contents('php://input'), true);
|
$data = json_decode(file_get_contents('php://input'), true);
|
||||||
@@ -60,6 +62,21 @@ if ($assignedTo === null || $assignedTo === '') {
|
|||||||
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
$success = $ticketModel->assignTicket($ticketId, $assignedTo, $userId);
|
||||||
if ($success) {
|
if ($success) {
|
||||||
$auditLogModel->log($userId, 'assign', 'ticket', $ticketId, ['assigned_to' => $assignedTo]);
|
$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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Get Comments API
|
||||||
|
* Returns paginated comments for a ticket (used by "Load more" on ticket view)
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/bootstrap.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/CommentModel.php';
|
||||||
|
require_once dirname(__DIR__) . '/models/TicketModel.php';
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
|
||||||
|
http_response_code(405);
|
||||||
|
echo json_encode(['success' => 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,
|
||||||
|
]);
|
||||||
@@ -26,6 +26,7 @@ try {
|
|||||||
require_once $commentModelPath;
|
require_once $commentModelPath;
|
||||||
require_once $auditLogModelPath;
|
require_once $auditLogModelPath;
|
||||||
require_once $workflowModelPath;
|
require_once $workflowModelPath;
|
||||||
|
require_once dirname(__DIR__) . '/helpers/NotificationHelper.php';
|
||||||
|
|
||||||
// Check authentication via session
|
// Check authentication via session
|
||||||
if (session_status() === PHP_SESSION_NONE) {
|
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 [
|
return [
|
||||||
'success' => true,
|
'success' => true,
|
||||||
'status' => $updateData['status'],
|
'status' => $updateData['status'],
|
||||||
|
|||||||
+11
-1
@@ -56,8 +56,18 @@ $GLOBALS['config'] = [
|
|||||||
|
|
||||||
// Matrix webhook (hookshot generic webhook URL)
|
// Matrix webhook (hookshot generic webhook URL)
|
||||||
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
'MATRIX_WEBHOOK_URL' => $envVars['MATRIX_WEBHOOK_URL'] ?? null,
|
||||||
// Comma-separated Matrix user IDs to @mention on new tickets (e.g. @jared:matrix.lotusguild.org)
|
// 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_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.)
|
// Domain settings for external integrations (webhooks, links, etc.)
|
||||||
// Set APP_DOMAIN in .env to override
|
// Set APP_DOMAIN in .env to override
|
||||||
|
|||||||
@@ -49,8 +49,10 @@ class TicketController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get comments for this ticket using CommentModel
|
// Load first page of comments; show "load more" if ticket has many
|
||||||
$comments = $this->commentModel->getCommentsByTicketId($id);
|
$commentPageSize = 50;
|
||||||
|
$totalComments = $this->commentModel->getCommentCount((int)$id);
|
||||||
|
$comments = $this->commentModel->getCommentsByTicketId($id, true, $commentPageSize, 0);
|
||||||
|
|
||||||
// Get timeline for this ticket
|
// Get timeline for this ticket
|
||||||
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
$timeline = $this->auditLogModel->getTicketTimeline($id);
|
||||||
|
|||||||
+142
-31
@@ -1,41 +1,17 @@
|
|||||||
<?php
|
<?php
|
||||||
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
require_once dirname(__DIR__) . '/helpers/UrlHelper.php';
|
||||||
|
require_once dirname(__DIR__) . '/helpers/SynapseHelper.php';
|
||||||
|
|
||||||
class NotificationHelper {
|
class NotificationHelper {
|
||||||
/**
|
|
||||||
* Send a Matrix webhook notification for a new ticket.
|
// ─── Internal: fire a webhook ─────────────────────────────────────────────
|
||||||
*
|
|
||||||
* @param string $ticketId Ticket ID (9-digit string)
|
private static function fire(array $payload): void {
|
||||||
* @param array $ticketData Ticket fields (title, priority, category, type, status, ...)
|
|
||||||
* @param string $trigger 'manual' (web UI) or 'automated' (API)
|
|
||||||
*/
|
|
||||||
public static function sendTicketNotification($ticketId, $ticketData, $trigger = 'manual') {
|
|
||||||
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
$webhookUrl = $GLOBALS['config']['MATRIX_WEBHOOK_URL'] ?? null;
|
||||||
if (empty($webhookUrl)) {
|
if (empty($webhookUrl)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse notify users from config (comma-separated Matrix user IDs)
|
|
||||||
$notifyRaw = $GLOBALS['config']['MATRIX_NOTIFY_USERS'] ?? '';
|
|
||||||
$notifyUsers = array_values(array_filter(array_map('trim', explode(',', $notifyRaw))));
|
|
||||||
|
|
||||||
// Extract hostname from [hostname] prefix in title
|
|
||||||
preg_match('/^\[([^\]]+)\]/', $ticketData['title'] ?? '', $m);
|
|
||||||
$source = $m[1] ?? ($trigger === 'automated' ? 'Automated' : 'Manual');
|
|
||||||
|
|
||||||
$payload = [
|
|
||||||
'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' => $notifyUsers,
|
|
||||||
];
|
|
||||||
|
|
||||||
$ch = curl_init($webhookUrl);
|
$ch = curl_init($webhookUrl);
|
||||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||||
curl_setopt($ch, CURLOPT_POST, 1);
|
curl_setopt($ch, CURLOPT_POST, 1);
|
||||||
@@ -48,11 +24,146 @@ class NotificationHelper {
|
|||||||
$curlError = curl_error($ch);
|
$curlError = curl_error($ch);
|
||||||
curl_close($ch);
|
curl_close($ch);
|
||||||
|
|
||||||
|
$id = $payload['ticket_id'] ?? '?';
|
||||||
if ($curlError) {
|
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) {
|
} 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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* SynapseHelper
|
||||||
|
*
|
||||||
|
* Resolves local (SSO) usernames → Matrix user IDs by querying the
|
||||||
|
* Synapse Admin REST API directly. No caching — every call is live
|
||||||
|
* so results never go stale.
|
||||||
|
*
|
||||||
|
* Required config (.env) keys:
|
||||||
|
* MATRIX_DOMAIN e.g. matrix.lotusguild.org
|
||||||
|
* SYNAPSE_ADMIN_URL e.g. http://10.10.10.29:8008 (internal client-API URL)
|
||||||
|
* SYNAPSE_ADMIN_TOKEN a Synapse admin access token
|
||||||
|
*/
|
||||||
|
class SynapseHelper {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a local SSO username to its Matrix user ID.
|
||||||
|
*
|
||||||
|
* Uses the Synapse Admin API v2 endpoint:
|
||||||
|
* GET /_synapse/admin/v2/users/@{username}:{domain}
|
||||||
|
*
|
||||||
|
* If the account exists in Synapse the method returns the Matrix ID string.
|
||||||
|
* If the account does not exist, or if Synapse is unreachable / not configured,
|
||||||
|
* it returns null silently (notifications are best-effort).
|
||||||
|
*
|
||||||
|
* @param string $username Local username (e.g. "jared")
|
||||||
|
* @return string|null Matrix user ID (e.g. "@jared:matrix.lotusguild.org") or null
|
||||||
|
*/
|
||||||
|
public static function resolveUsername(string $username): ?string {
|
||||||
|
$baseUrl = $GLOBALS['config']['SYNAPSE_ADMIN_URL'] ?? null;
|
||||||
|
$token = $GLOBALS['config']['SYNAPSE_ADMIN_TOKEN'] ?? null;
|
||||||
|
$domain = $GLOBALS['config']['MATRIX_DOMAIN'] ?? null;
|
||||||
|
|
||||||
|
if (!$baseUrl || !$token || !$domain) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$matrixId = '@' . rawurlencode($username) . ':' . $domain;
|
||||||
|
$url = rtrim($baseUrl, '/') . '/_synapse/admin/v2/users/' . rawurlencode($matrixId);
|
||||||
|
|
||||||
|
$ch = curl_init($url);
|
||||||
|
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||||
|
'Authorization: Bearer ' . $token,
|
||||||
|
'Accept: application/json',
|
||||||
|
]);
|
||||||
|
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||||
|
curl_setopt($ch, CURLOPT_TIMEOUT, 5);
|
||||||
|
|
||||||
|
$body = curl_exec($ch);
|
||||||
|
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||||
|
$curlError = curl_error($ch);
|
||||||
|
curl_close($ch);
|
||||||
|
|
||||||
|
if ($curlError) {
|
||||||
|
error_log("SynapseHelper: cURL error resolving '{$username}': {$curlError}");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($httpCode === 200) {
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
// Confirm the response contains the name we expect
|
||||||
|
if (!empty($data['name'])) {
|
||||||
|
return $data['name']; // e.g. "@jared:matrix.lotusguild.org"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 404 = user not found in Synapse; other codes = error
|
||||||
|
if ($httpCode !== 404) {
|
||||||
|
error_log("SynapseHelper: unexpected HTTP {$httpCode} resolving '{$username}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve multiple usernames to Matrix IDs.
|
||||||
|
* Returns only those that were successfully confirmed in Synapse.
|
||||||
|
*
|
||||||
|
* @param string[] $usernames
|
||||||
|
* @return string[] Matrix user IDs
|
||||||
|
*/
|
||||||
|
public static function resolveUsernames(array $usernames): array {
|
||||||
|
$ids = [];
|
||||||
|
foreach ($usernames as $username) {
|
||||||
|
$id = self::resolveUsername($username);
|
||||||
|
if ($id !== null) {
|
||||||
|
$ids[] = $id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $ids;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
?>
|
||||||
@@ -115,6 +115,10 @@ switch (true) {
|
|||||||
require_once 'api/get_users.php';
|
require_once 'api/get_users.php';
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case $requestPath == '/api/get_comments.php':
|
||||||
|
require_once 'api/get_comments.php';
|
||||||
|
break;
|
||||||
|
|
||||||
case $requestPath == '/api/assign_ticket.php':
|
case $requestPath == '/api/assign_ticket.php':
|
||||||
require_once 'api/assign_ticket.php';
|
require_once 'api/assign_ticket.php';
|
||||||
break;
|
break;
|
||||||
|
|||||||
+101
-7
@@ -50,10 +50,35 @@ class CommentModel {
|
|||||||
return $users;
|
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();
|
$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) {
|
if ($hasThreading) {
|
||||||
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
$sql = "SELECT tc.*, u.display_name, u.username, tc.parent_comment_id, tc.thread_depth
|
||||||
FROM ticket_comments tc
|
FROM ticket_comments tc
|
||||||
@@ -70,16 +95,21 @@ class CommentModel {
|
|||||||
ORDER BY tc.created_at DESC";
|
ORDER BY tc.created_at DESC";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($limit > 0) {
|
||||||
|
$sql .= " LIMIT ? OFFSET ?";
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $this->conn->prepare($sql);
|
$stmt = $this->conn->prepare($sql);
|
||||||
|
if ($limit > 0) {
|
||||||
|
$stmt->bind_param("iii", $ticketId, $limit, $offset);
|
||||||
|
} else {
|
||||||
$stmt->bind_param("i", $ticketId);
|
$stmt->bind_param("i", $ticketId);
|
||||||
|
}
|
||||||
$stmt->execute();
|
$stmt->execute();
|
||||||
$result = $stmt->get_result();
|
$result = $stmt->get_result();
|
||||||
|
|
||||||
$comments = [];
|
|
||||||
$commentMap = [];
|
$commentMap = [];
|
||||||
|
|
||||||
while ($row = $result->fetch_assoc()) {
|
while ($row = $result->fetch_assoc()) {
|
||||||
// Use display_name from users table if available, fallback to user_name field
|
|
||||||
if (!empty($row['display_name'])) {
|
if (!empty($row['display_name'])) {
|
||||||
$row['display_name_formatted'] = $row['display_name'];
|
$row['display_name_formatted'] = $row['display_name'];
|
||||||
} else {
|
} else {
|
||||||
@@ -90,8 +120,9 @@ class CommentModel {
|
|||||||
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
$row['thread_depth'] = $row['thread_depth'] ?? 0;
|
||||||
$commentMap[$row['comment_id']] = $row;
|
$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) {
|
if ($hasThreading && $threaded) {
|
||||||
$rootComments = [];
|
$rootComments = [];
|
||||||
foreach ($commentMap as $id => $comment) {
|
foreach ($commentMap as $id => $comment) {
|
||||||
@@ -102,10 +133,73 @@ class CommentModel {
|
|||||||
return $rootComments;
|
return $rootComments;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flat list
|
|
||||||
return array_values($commentMap);
|
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
|
* Check if threading columns exist
|
||||||
*/
|
*/
|
||||||
|
|||||||
+182
-5
@@ -10,12 +10,13 @@ require_once __DIR__ . '/../middleware/CsrfMiddleware.php';
|
|||||||
$nonce = SecurityHeadersMiddleware::getNonce();
|
$nonce = SecurityHeadersMiddleware::getNonce();
|
||||||
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
|
$pageTitle = 'Ticket #' . htmlspecialchars($ticket['ticket_id'] ?? '');
|
||||||
$activeNav = 'dashboard';
|
$activeNav = 'dashboard';
|
||||||
$pageStyles = ['/assets/css/ticket.css?v=20260327'];
|
$_v = $GLOBALS['config']['ASSET_VERSION'] ?? '1';
|
||||||
|
$pageStyles = ["/assets/css/ticket.css?v={$_v}"];
|
||||||
$pageScripts = [
|
$pageScripts = [
|
||||||
'/assets/js/markdown.js?v=20260327',
|
"/assets/js/markdown.js?v={$_v}",
|
||||||
'/assets/js/ticket.js?v=20260327',
|
"/assets/js/ticket.js?v={$_v}",
|
||||||
'/assets/js/keyboard-shortcuts.js?v=20260327',
|
"/assets/js/keyboard-shortcuts.js?v={$_v}",
|
||||||
'/assets/js/settings.js?v=20260327',
|
"/assets/js/settings.js?v={$_v}",
|
||||||
];
|
];
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
@@ -78,6 +79,10 @@ $json_priority = json_encode($ticket['priority'], JSON_HEX_TAG);
|
|||||||
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
$json_category = json_encode($ticket['category'], JSON_HEX_TAG);
|
||||||
$json_type = json_encode($ticket['type'], 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
|
$pageInlineScript = <<<JS
|
||||||
window.ticketData = {
|
window.ticketData = {
|
||||||
ticket_id: {$json_ticket_id},
|
ticket_id: {$json_ticket_id},
|
||||||
@@ -87,6 +92,11 @@ window.ticketData = {
|
|||||||
category: {$json_category},
|
category: {$json_category},
|
||||||
type: {$json_type},
|
type: {$json_type},
|
||||||
updated_at: {$json_updated_at},
|
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;
|
window.ticketData.id = window.ticketData.ticket_id;
|
||||||
if (window.lt) lt.keys.initDefaults();
|
if (window.lt) lt.keys.initDefaults();
|
||||||
@@ -450,6 +460,16 @@ include __DIR__ . '/layout_header.php';
|
|||||||
}
|
}
|
||||||
foreach ($comments as $comment): renderComment($comment, $currentUserId, $isAdmin); endforeach;
|
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 ?>
|
<?php endif ?>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user