OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)

This commit is contained in:
wangruiguo
2026-09-03 18:49:20 +08:00
commit d647428529
3501 changed files with 1988906 additions and 0 deletions

View File

@@ -0,0 +1,82 @@
<?php
namespace Leantime\Domain\Comments\Controllers;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Controller\Controller;
use Leantime\Core\Controller\Frontcontroller;
use Leantime\Domain\Comments\Services\Comments as CommentService;
use Symfony\Component\HttpFoundation\Response;
class ShowAll extends Controller
{
private CommentService $commentService;
private $module;
private $id;
private $entity;
/**
* init - initialize private variables
*
* @throws Exception
*/
public function init(
CommentService $commentService
): void {
$this->commentService = $commentService;
}
/**
* @throws Exception
*/
public function get($params): Response
{
if (! isset($params['module'], $params['entitiyId'], $params['entity'])) {
throw new Exception('comments module needs to be initialized with module, entity id and entity');
}
$this->module = $params['module'];
$this->id = $params['entitiyId'];
$this->entity = $params['entity'];
$comments = $this->commentService->getComments($this->module, $this->id);
$this->tpl->assign('numComments', count($comments));
$this->tpl->assign('comments', $comments);
// Delete comment
if (isset($params['delComment']) === true) {
$commentId = (int) ($params['delComment']);
if ($this->commentService->deleteComment($commentId)) {
$this->tpl->setNotification($this->language->__('notifications.comment_deleted'), 'success');
return Frontcontroller::redirect(BASE_URL.'/tickets/showTicket/'.$this->id);
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_deleted_error'), 'error');
}
}
return $this->tpl->displayPartial('comments.showAll');
}
/**
* @throws BindingResolutionException
*/
public function post($params): Response
{
if (isset($params['comment']) === true) {
if ($this->commentService->addComment($_POST, $this->module, $this->id, $this->entity)) {
$this->tpl->setNotification($this->language->__('notifications.comment_create_success'), 'success');
} else {
$this->tpl->setNotification($this->language->__('notifications.comment_create_error'), 'error');
}
}
return new Response;
}
}

View File

@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
namespace Leantime\Domain\Comments\Hxcontrollers;
use Leantime\Core\Controller\HtmxController;
use Leantime\Domain\Comments\Services\Comments as CommentService;
/**
* HTMX Controller for managing comment reactions (toggle, get).
*/
class Reactions extends HtmxController
{
protected static string $view = 'comments::partials.reactions';
private CommentService $commentService;
public function init(CommentService $commentService): void
{
$this->commentService = $commentService;
}
/**
* Toggle a reaction on a comment.
*/
public function toggle(): void
{
$commentId = (int) $this->incomingRequest->query->get('commentId', 0);
$reaction = (string) $this->incomingRequest->request->get('reaction', '');
$userId = (int) session('userdata.id');
if (! $commentId || ! $reaction || ! $userId) {
$this->assignEmptyReactions();
return;
}
if (! $this->commentService->toggleCommentReaction($userId, $commentId, $reaction)) {
$this->assignEmptyReactions();
return;
}
$this->loadReactions($commentId, $userId);
}
/**
* Get reactions for a comment.
*/
public function get(): void
{
$commentId = (int) $this->incomingRequest->query->get('commentId', 0);
$userId = (int) session('userdata.id');
if (! $commentId) {
$this->assignEmptyReactions();
return;
}
$this->loadReactions($commentId, $userId);
}
/**
* Assign reaction data for a comment to the template.
*/
private function loadReactions(int $commentId, int $userId): void
{
$reactionData = $this->commentService->getCommentReactions($commentId, $userId);
$this->tpl->assign('reactions', $reactionData['reactions']);
$this->tpl->assign('commentId', $commentId);
$this->tpl->assign('userReactions', $reactionData['userReactions']);
}
/**
* Assign the empty reaction state to the template.
*/
private function assignEmptyReactions(): void
{
$this->tpl->assign('reactions', []);
$this->tpl->assign('commentId', 0);
$this->tpl->assign('userReactions', []);
}
}

View File

@@ -0,0 +1,74 @@
leantime.commentsController = (function () {
var enableCommenterForms = function () {
// Show the "Add new comment" toggler that makeInputReadonly may have hidden
jQuery("[class^='mainToggler']").show();
// Keep per-comment reply/edit boxes (legacy .commentBox class) hidden;
// they open on-demand via toggleCommentBoxes(). These boxes now live
// outside .replies (so editing a comment with replies no longer jumps it
// below them), so hide by the class itself rather than by .replies
// containment. The "new comment" form uses commentBox-{hash} and is
// unaffected.
jQuery(".commentBox").hide();
jQuery(".deleteComment, .replyButton").show();
// Enable Tiptap editors in comment areas
jQuery(".commentReply .tiptap-wrapper").each(function() {
var editorEl = jQuery(this).find('.tiptap-editor')[0];
if (editorEl && window.leantime && window.leantime.tiptapController) {
var editor = leantime.tiptapController.registry.get(editorEl);
if (editor) {
editor.setEditable(true);
}
}
});
jQuery(".commentReply .tiptap-toolbar").show();
// Re-enable form controls in all comment areas (both legacy .commentBox
// and hashed commentBox-{hash} containers) without changing visibility (#3194)
jQuery(".commenterFields, .commentBox, [class*='commentBox-']")
.find("input, textarea, button, select")
.prop("readonly", false)
.prop("disabled", false);
};
var toggleCommentBoxes = function (id) {
if (id == 0) {
jQuery('#mainToggler').hide();
} else {
jQuery('#mainToggler').show();
}
// Destroy existing Tiptap editors in comment boxes
if (window.leantime && window.leantime.tiptapController && window.leantime.tiptapController.registry) {
jQuery('.commentBox .tiptap-editor').each(function() {
leantime.tiptapController.registry.destroy(this);
});
}
jQuery('.commentBox .tiptap-wrapper').remove();
jQuery('.commentBox textarea').remove();
jQuery('.commentBox').hide('fast', function () {});
jQuery('#comment' + id + ' .commentReply').prepend('<textarea rows="5" cols="75" name="text" class="tiptapSimple"></textarea>');
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
jQuery('#comment' + id + '').show('fast');
jQuery('#father').val(id);
};
// Make public what you want to have public, everything else is private
return {
enableCommenterForms:enableCommenterForms,
toggleCommentBoxes:toggleCommentBoxes
};
})();

View File

@@ -0,0 +1,39 @@
<?php
namespace Leantime\Domain\Comments\Permissions;
use Leantime\Core\Auth\Permissions\Permission;
use Leantime\Core\Auth\Permissions\ProvidesPermissions;
/**
* The Comments permission vocabulary — verbs only.
*
* Comments are project-scoped (a comment lives on a project-owned entity). Note the
* distinct `moderate` verb: editing/deleting *someone else's* comment is a manager+
* capability (the matrix grants `moderate` only via `manager: project *`), while the
* author always edits/deletes their own via the ownership check in the service. Keeping a
* dedicated `moderate` verb avoids over-granting (a `comments.edit` would seed at editor+).
*/
final class CommentsPermissions implements ProvidesPermissions
{
public const VIEW = 'comments.view';
public const CREATE = 'comments.create';
/** Edit/delete ANY comment (not your own) — manager+. Authors bypass via ownership. */
public const MODERATE = 'comments.moderate';
public function domain(): string
{
return 'comments';
}
public function permissions(): array
{
return [
new Permission(self::VIEW, 'View comments'),
new Permission(self::CREATE, 'Add comments'),
new Permission(self::MODERATE, 'Moderate (edit/delete) any comment'),
];
}
}

View File

@@ -0,0 +1,252 @@
<?php
namespace Leantime\Domain\Comments\Repositories;
use Illuminate\Database\ConnectionInterface;
use Leantime\Core\Db\Db as DbCore;
class Comments
{
private ConnectionInterface $db;
public function __construct(DbCore $db)
{
$this->db = $db->getConnection();
}
public function getComments(string $module, int $moduleId, int $parent = 0, int $orderByState = 0): false|array
{
$orderBy = $orderByState === 1 ? 'asc' : 'desc';
$query = $this->db->table('zp_comment as comment')
->select(
'comment.id',
'comment.text',
'comment.date',
'comment.moduleId',
'comment.userId',
'comment.commentParent',
'comment.status',
'user.firstname',
'user.lastname',
'user.profileId',
'user.modified AS userModified'
)
->addSelect('comment.date AS rawDate')
->join('zp_user as user', 'comment.userId', '=', 'user.id')
->where('comment.moduleId', $moduleId)
->where('comment.module', $module);
if ($parent >= 0) {
$query->where('comment.commentParent', $parent);
}
$results = $query->orderBy('comment.date', $orderBy)->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
/**
* @return int|mixed
*/
public function countComments(?string $module = null, ?int $moduleId = null): mixed
{
$query = $this->db->table('zp_comment as comment');
if ($module !== null) {
$query->where('module', $module);
}
if ($moduleId !== null) {
$query->where('moduleId', $moduleId);
}
return $query->count();
}
public function getReplies(int $id): false|array
{
$results = $this->db->table('zp_comment as comment')
->select(
'comment.id',
'comment.text',
'comment.date',
'comment.moduleId',
'comment.userId',
'comment.commentParent',
'user.firstname',
'user.lastname',
'user.profileId',
'user.modified AS userModified'
)
->join('zp_user as user', 'comment.userId', '=', 'user.id')
->where('comment.commentParent', $id)
->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
public function getComment(int $id): array|false
{
$result = $this->db->table('zp_comment as comment')
->select(
'comment.id',
'comment.text',
'comment.date',
'comment.module',
'comment.moduleId',
'comment.userId',
'comment.commentParent',
'comment.status',
'user.firstname',
'user.lastname'
)
->join('zp_user as user', 'comment.userId', '=', 'user.id')
->where('comment.id', $id)
->first();
return $result ? (array) $result : false;
}
/**
* Resolve the owning project of a comment target (module + entity id) for authorization.
*
* Comments are cross-module; each module maps to a project differently:
* - project -> the moduleId IS the project id
* - ticket -> zp_tickets.projectId
* - canvas family -> the moduleId is a zp_canvas_items row; project via its canvas. Only the
* KNOWN canvas comment modules are resolved this way: 'article', 'idea', and every
* "<type>canvasitem" (goalcanvasitem, leancanvasitem, wikicanvasitem, ...).
* - client / anything else -> null (company-scoped or unknown; the caller falls back to a
* session-scoped capability check so behavior is unchanged for those targets). An unknown
* module is NOT run through the canvas lookup, so it can never accidentally resolve a project
* from a colliding zp_canvas_items id.
*
* Direct table reads are used deliberately (no cross-domain service calls) to keep this a
* decoupled, side-effect-free authorization lookup that cannot recurse into other gates.
*/
public function resolveModuleProjectId(string $module, int $moduleId): ?int
{
if ($moduleId <= 0) {
return null;
}
if ($module === 'project') {
return $moduleId;
}
if ($module === 'ticket') {
$projectId = $this->db->table('zp_tickets')->where('id', $moduleId)->value('projectId');
return $projectId !== null ? (int) $projectId : null;
}
// Canvas-backed comment targets store the canvas-item id as the moduleId. Restrict to the
// known canvas comment modules so an unknown module falls through to null (session-scoped).
if ($module === 'article' || $module === 'idea' || str_ends_with($module, 'canvasitem')) {
$projectId = $this->db->table('zp_canvas_items')
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->where('zp_canvas_items.id', $moduleId)
->value('zp_canvas.projectId');
return $projectId !== null ? (int) $projectId : null;
}
return null;
}
public function addComment(array $values, string $module): false|string
{
$id = $this->db->table('zp_comment')->insertGetId([
'text' => $values['text'],
'userId' => $values['userId'],
'date' => $values['date'],
'moduleId' => $values['moduleId'],
'module' => $module,
'commentParent' => $values['commentParent'],
'status' => $values['status'] ?? '',
]);
return $id ? (string) $id : false;
}
public function deleteComment(int $id): bool
{
return $this->db->table('zp_comment')
->where('id', $id)
->delete() > 0;
}
public function editComment(string $text, int $id): bool
{
return $this->db->table('zp_comment')
->where('id', $id)
->update(['text' => $text]) >= 0;
}
public function getAllAccountComments(?int $projectId, ?int $moduleId): array|false
{
$userId = session('userdata.id') ?? -1;
$clientId = session('userdata.clientId') ?? -1;
$requesterRole = session()->exists('userdata') ? session('userdata.role') : -1;
$query = $this->db->table('zp_comment as comment')
->select(
'comment.id',
'comment.module',
'comment.text',
'comment.date',
'comment.moduleId',
'comment.userId',
'comment.commentParent',
'comment.status',
'zp_projects.id AS projectId'
)
->leftJoin('zp_tickets', 'comment.moduleId', '=', 'zp_tickets.id')
->leftJoin('zp_canvas_items', 'comment.moduleId', '=', 'zp_canvas_items.id')
->leftJoin('zp_canvas', 'zp_canvas.id', '=', 'zp_canvas_items.canvasId')
->leftJoin('zp_projects', function ($join) {
$join->on('zp_canvas.projectId', '=', 'zp_projects.id')
->orOn('zp_tickets.projectId', '=', 'zp_projects.id');
})
->where(function ($q) use ($userId, $clientId, $requesterRole) {
$q->whereIn('zp_projects.id', function ($subquery) use ($userId) {
$subquery->select('projectId')
->from('zp_relationuserproject')
->where('userId', $userId);
})
->orWhere('zp_projects.psettings', 'all')
->orWhere(function ($q2) use ($clientId) {
$q2->where('zp_projects.psettings', 'clients')
->where('zp_projects.clientId', $clientId);
})
->orWhere(function ($q3) use ($requesterRole) {
if (in_array($requesterRole, ['admin', 'manager'])) {
$q3->whereRaw('1=1');
}
});
});
if (isset($projectId) && $projectId > 0) {
$query->where('zp_projects.id', $projectId);
}
if (isset($moduleId) && $moduleId > 0) {
$query->where('comment.moduleId', $moduleId);
}
$results = $query->groupBy(
'comment.id',
'comment.module',
'comment.text',
'comment.date',
'comment.moduleId',
'comment.userId',
'comment.commentParent',
'comment.status',
'zp_projects.id'
)->get();
return array_map(fn ($item) => (array) $item, $results->toArray());
}
}

View File

@@ -0,0 +1,407 @@
<?php
namespace Leantime\Domain\Comments\Services;
use Illuminate\Contracts\Container\BindingResolutionException;
use Leantime\Core\Auth\Permissions\RequiresPermission;
use Leantime\Core\Domains\BaseService;
use Leantime\Core\Language as LanguageCore;
use Leantime\Domain\Comments\Permissions\CommentsPermissions;
use Leantime\Domain\Comments\Repositories\Comments as CommentRepository;
use Leantime\Domain\Notifications\Models\Notification;
use Leantime\Domain\Projects\Services\Projects as ProjectService;
use Leantime\Domain\Reactions\Services\Reactions as ReactionsService;
/**
* @api
*/
class Comments extends BaseService
{
private CommentRepository $commentRepository;
private ProjectService $projectService;
private LanguageCore $language;
private ReactionsService $reactionsService;
public function __construct(
CommentRepository $commentRepository,
ProjectService $projectService,
LanguageCore $language,
ReactionsService $reactionsService
) {
$this->commentRepository = $commentRepository;
$this->projectService = $projectService;
$this->language = $language;
$this->reactionsService = $reactionsService;
}
/**
* Resolve the entity object backing a comment when the caller didn't
* pass one. Web controllers usually already have the entity loaded
* before invoking addComment(); RPC callers don't, and shouldn't
* have to pre-fetch the entire ticket just to leave a comment.
*/
private function loadEntityForComment(string $module, int $entityId)
{
try {
if ($module === 'ticket') {
$ticketService = app()->make(\Leantime\Domain\Tickets\Services\Tickets::class);
$ticket = $ticketService->getTicket($entityId);
return $ticket ?: null;
}
if ($module === 'project') {
$projectService = app()->make(\Leantime\Domain\Projects\Services\Projects::class);
return $projectService->getProject($entityId) ?: null;
}
} catch (\Throwable $e) {
return null;
}
return null;
}
/**
* @api
*/
#[RequiresPermission(CommentsPermissions::VIEW, entityScoped: true)]
public function getComments($module, $entityId, int $commentOrder = 0, int $parent = 0): false|array
{
// IDOR fence: comments are read by (module, entityId) with no project scoping in the repo,
// so authorize VIEW against the host entity's REAL project — a foreign id can no longer leak
// another project's comment thread over RPC. A null project (client/company-scoped target or
// an unknown module) falls back to a session-scoped capability check (unchanged behavior).
$projectId = $this->commentRepository->resolveModuleProjectId((string) $module, (int) $entityId);
$this->authorize(CommentsPermissions::VIEW, $projectId);
return $this->commentRepository->getComments($module, $entityId, $parent, $commentOrder);
}
/**
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
public function addComment($values, $module, $entityId, $entity = null): bool
{
// RPC callers (mobile) typically don't pre-load the entity — they
// just know module + entityId. Load it server-side so they don't
// have to ship a whole ticket payload over the wire just to comment.
if ($entity === null && $module && $entityId) {
$entity = $this->loadEntityForComment($module, (int) $entityId);
}
// Commenting is a commenter+ capability. Resolve the host entity's project so the
// check is scoped to it (ticket -> projectId; project -> its own id), then authorize.
$projectId = is_object($entity) && isset($entity->projectId)
? (int) $entity->projectId
: ($module === 'project' ? (int) $entityId : null);
// Fall back to resolving the host entity's project by (module, id) so canvas-family and
// other targets (whose $entity may be an array or unloaded) are also project-fenced.
if ($projectId === null) {
$projectId = $this->commentRepository->resolveModuleProjectId((string) $module, (int) $entityId);
}
$this->authorize(CommentsPermissions::CREATE, $projectId);
// Default father (parent comment id) to 0 if not provided. The
// original code REQUIRED it via isset(), which forced every caller
// to send a value even when there was no parent. 0 is the sentinel
// for "top-level comment, no parent."
if (! isset($values['father'])) {
$values['father'] = $values['parentId'] ?? 0;
}
if (isset($values['text']) && $values['text'] != '' && isset($values['father']) && isset($module) && isset($entityId) && isset($entity)) {
$mapper = [
'text' => $values['text'],
'date' => dtHelper()->dbNow()->formatDateTimeForDb(),
'userId' => (session('userdata.id')),
'moduleId' => $entityId,
'commentParent' => ($values['father']),
'status' => $values['status'] ?? '',
];
$comment = $this->commentRepository->addComment($mapper, $module);
if ($comment) {
$mapper['id'] = $comment;
$currentUrl = CURRENT_URL;
switch ($module) {
case 'ticket':
$subject = sprintf($this->language->__('email_notifications.new_comment_todo_with_type_subject'), $this->language->__('label.'.strtolower($entity->type)), $entity->id, strip_tags($entity->headline));
$message = sprintf($this->language->__('email_notifications.new_comment_todo_with_type_message'), session('userdata.name'), $this->language->__('label.'.strtolower($entity->type)), strip_tags($entity->headline), strip_tags($values['text']));
$linkLabel = $this->language->__('email_notifications.new_comment_todo_cta');
$currentUrl = BASE_URL.'#/tickets/showTicket/'.$entity->id;
break;
case 'project':
$subject = sprintf($this->language->__('email_notifications.new_comment_project_subject'), $entityId, strip_tags($entity['name']));
$message = sprintf($this->language->__('email_notifications.new_comment_project_message'), session('userdata.name'), strip_tags($entity['name']));
$linkLabel = $this->language->__('email_notifications.new_comment_project_cta');
break;
default:
$subject = $this->language->__('email_notifications.new_comment_general_subject');
$message = sprintf($this->language->__('email_notifications.new_comment_general_message'), session('userdata.name'));
$linkLabel = $this->language->__('email_notifications.new_comment_general_cta');
break;
}
$notification = app()->make(Notification::class);
$urlQueryParameter = str_contains($currentUrl, '?') ? '&' : '?';
$notification->url = [
'url' => $currentUrl.$urlQueryParameter.'projectId='.session('currentProject'),
'text' => $linkLabel,
];
$notification->entity = $mapper;
$notification->module = 'comments';
$notification->action = 'commented';
// session('currentProject') is set when a user is browsing
// a project on web; RPC callers (mobile) don't have that
// session key populated, and the Notification model types
// projectId as `int` (rejects null). Fall back to the
// commented-on entity's project so we always have a real
// integer.
$entityProjectId = is_object($entity)
? ($entity->projectId ?? 0)
: (is_array($entity) ? ($entity['projectId'] ?? $entity['id'] ?? 0) : 0);
$notification->projectId = (int) (session('currentProject') ?? $entityProjectId);
$notification->subject = $subject;
$notification->authorId = session('userdata.id');
$notification->message = $message;
$this->projectService->notifyProjectUsers($notification);
return true;
}
}
return false;
}
/**
* Checks whether the current user is authorized to modify a comment.
* The caller must be the comment author or have at least manager role.
*
* @param int $commentId The comment ID to check
* @return bool True if authorized, false otherwise
*/
private function canModifyComment(int $commentId): bool
{
$comment = $this->commentRepository->getComment($commentId);
if (! $comment) {
return false;
}
$currentUserId = session('userdata.id');
// Comment author can always modify their own comment.
if ((int) $comment['userId'] === (int) $currentUserId) {
return true;
}
// Otherwise moderation (editing/deleting someone else's comment) is a manager+ capability,
// scoped to the comment's OWN project so a manager in project A cannot moderate a comment on
// an entity in project B by id. A null project (client/company-scoped or unknown module)
// falls back to a session-scoped moderate check (unchanged behavior for those targets).
$projectId = $this->commentRepository->resolveModuleProjectId(
(string) ($comment['module'] ?? ''),
(int) ($comment['moduleId'] ?? 0)
);
return $this->can(CommentsPermissions::MODERATE, $projectId);
}
/**
* Edit a comment. The caller must be the comment author or a manager+.
*
* @throws BindingResolutionException
*
* @api
*/
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
public function editComment($values, $id): bool
{
if (! $this->canModifyComment((int) $id)) {
return false;
}
return $this->commentRepository->editComment($values['text'], $id);
}
/**
* Delete a comment. The caller must be the comment author or a manager+.
*
* @api
*/
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
public function deleteComment($commentId): bool
{
if (! $this->canModifyComment((int) $commentId)) {
return false;
}
return $this->commentRepository->deleteComment($commentId);
}
/**
* @param ?int $projectId Project ID
* @param ?int $moduleId Id of the entity to pull comments from
* @return array
*
* @api
*/
#[RequiresPermission(CommentsPermissions::VIEW, projectIdParam: 'projectId')]
public function pollComments(?int $projectId = null, ?int $moduleId = null): array|false
{
$comments = $this->commentRepository->getAllAccountComments($projectId, $moduleId);
foreach ($comments as $key => $comment) {
if (dtHelper()->isValidDateString($comment['date'])) {
$comments[$key]['date'] = dtHelper()->parseDbDateTime($comment['date'])->toIso8601ZuluString();
} else {
$comments[$key]['date'] = null;
}
}
return $comments;
}
/**
* Toggle a sentiment reaction on a comment for a given user.
*
* Enforces the domain rule that a user may only have one sentiment
* reaction per comment: clicking the reaction the user already has
* removes it (toggle off); clicking a different reaction removes any
* existing reactions first, then adds the new one. Unknown reaction
* types are rejected.
*
* @param int $userId Ignored — reactions always act as the session user (kept for RPC
* signature compatibility). See the in-body session pin.
* @param int $commentId The comment being reacted to
* @param string $reaction The reaction code (e.g. an emoji key)
* @return bool True when the toggle was applied, false when the reaction
* type is unknown and nothing was changed
*
* @api
*/
#[RequiresPermission(CommentsPermissions::CREATE, entityScoped: true)]
public function toggleCommentReaction(int $userId, int $commentId, string $reaction): bool
{
// Reactions act on behalf of the SESSION user only. Ignore any caller-supplied id so a
// client cannot toggle reactions as another user (the $userId param is kept for RPC
// signature compatibility but is not trusted).
$userId = (int) session('userdata.id');
// Validate reaction against known types
if ($this->reactionsService->getReactionType($reaction) === false) {
return false;
}
// IDOR fence: gate against the comment's OWN project (null -> session-scoped fallback), so
// reactions can't be toggled on another project's comment by id. SOFT-deny (same false
// return as a missing comment) rather than throw, so a denied cross-project comment is
// indistinguishable from a non-existent one — no commentId existence oracle.
$comment = $this->commentRepository->getComment($commentId);
if (! $comment) {
return false;
}
if (! $this->can(
CommentsPermissions::CREATE,
$this->commentRepository->resolveModuleProjectId(
(string) ($comment['module'] ?? ''),
(int) ($comment['moduleId'] ?? 0)
)
)) {
return false;
}
// Check if user already has this exact reaction
$existingSameReaction = $this->reactionsService->getUserReactions($userId, 'comment', $commentId, $reaction);
if (! empty($existingSameReaction)) {
// User clicked the same reaction - remove it (toggle off)
$this->reactionsService->removeReaction($userId, 'comment', $commentId, $reaction);
return true;
}
// User wants to add a reaction - first remove any existing reactions
// (only one sentiment reaction allowed per user per comment)
$allUserReactions = $this->reactionsService->getUserReactions($userId, 'comment', $commentId);
if (is_array($allUserReactions)) {
foreach ($allUserReactions as $existingReaction) {
$this->reactionsService->removeReaction($userId, 'comment', $commentId, $existingReaction['reaction']);
}
}
// Now add the new reaction
$this->reactionsService->addReaction($userId, 'comment', $commentId, $reaction);
return true;
}
/**
* Build the reaction view data for a comment.
*
* Returns the grouped reactions (with user names for tooltips) plus a
* flat list of the given user's reaction codes for the comment, ready
* to be assigned to the template.
*
* @param int $commentId The comment to load reactions for
* @param int $userId The current user id (0 when anonymous)
* @return array{reactions: array, userReactions: list<string>} View data
*
* @api
*/
#[RequiresPermission(CommentsPermissions::VIEW, entityScoped: true)]
public function getCommentReactions(int $commentId, int $userId): array
{
// IDOR fence: gate VIEW against the comment's OWN project before exposing reactor
// identities/sentiment, closing the cross-project reaction-read leak by comment id (RPC +
// Hx). SOFT-deny (same empty payload as a missing comment) rather than throw, so a denied
// cross-project comment is indistinguishable from a non-existent one — no existence oracle.
$comment = $this->commentRepository->getComment($commentId);
if (! $comment) {
return ['reactions' => [], 'userReactions' => []];
}
if (! $this->can(
CommentsPermissions::VIEW,
$this->commentRepository->resolveModuleProjectId(
(string) ($comment['module'] ?? ''),
(int) ($comment['moduleId'] ?? 0)
)
)) {
return ['reactions' => [], 'userReactions' => []];
}
// Get reactions with user names for tooltips
$reactionsWithUsers = $this->reactionsService->getEntityReactionsWithUsers('comment', $commentId);
// Flatten the user's reactions for this comment into a list of codes
$userReactionsList = [];
if ($userId) {
$userReactionsData = $this->reactionsService->getUserReactions($userId, 'comment', $commentId);
if (is_array($userReactionsData)) {
foreach ($userReactionsData as $r) {
$userReactionsList[] = $r['reaction'];
}
}
}
return [
'reactions' => $reactionsWithUsers ?: [],
'userReactions' => $userReactionsList,
];
}
}

View File

@@ -0,0 +1,9 @@
<div class="commentBox tw-hidden" id="comment{!! $commentId !!}">
<div class="commentImage">
<x-users::profile-image :user="$user" />
</div>
<div class="commentReply">
<x-global::forms.button tag="input" inputType="submit" contentRole="default" :labelText="__('links.reply')" name="comment" />
</div>
<div class="clearall"></div>
</div>

View File

@@ -0,0 +1,41 @@
<div id="#{{ $comment['id'] }}">
<div class="commentImage">
<x-users::profile-image :user="array('id'=> $comment['userId'], 'modified' => $comment['userModified'])" />
</div>
<div class="commentMain">
<div class="commentContent">
<div class="right commendDate">
{!! sprintf(
__('text.written_on'),
format($comment['date'])->date(),
format($comment['date'])->time()
) !!}
</div>
<span class="name">{!! sprintf(
__('text.full_name'),
$tpl->escape($comment['firstname']),
$tpl->escape($comment['lastname'])
) !!}</span>
<div class="text">
{!! $tpl->escapeMinimal($comment['text']) !!}
</div>
</div>
<div class="commentLinks">
@if ($login::userIsAtLeast(\Leantime\Domain\Auth\Models\Roles::$commenter))
<a href="javascript:void(0);"
onclick="leantime.commentsController.toggleCommentBoxes({{ $comment['commentParent'] }})">
<span class="fa fa-reply"></span> {{ __('links.reply') }}
</a>
@if($comment['userId'] == session("userdata.id"))
<a href="{{ CURRENT_URL }}?delComment={{ $comment['id'] }}"
class="deleteComment">
<span class="fa fa-trash"></span> {{ __('links.delete') }}
</a>
@endif
@endif
</div>
</div>
<div class="clearall"></div>
</div>

View File

@@ -0,0 +1,56 @@
@props([
'reactions' => [],
'commentId' => 0,
'userReactions' => []
])
@php
$emojiMap = [
'like' => '👍',
'love' => '❤️',
'celebrate' => '🎉',
'funny' => '😄',
'interesting' => '🤔',
'support' => '💯',
'sad' => '😥',
'anger' => '😡',
];
@endphp
<span class="comment-reactions" id="reactions-{{ $commentId }}">
@if(count($reactions) > 0)
<span class="reaction-list">
@foreach($reactions as $reactionData)
@php
$reactionKey = $reactionData['reaction'];
$emoji = $emojiMap[$reactionKey] ?? $reactionKey;
$isActive = in_array($reactionKey, $userReactions);
$userNames = [];
if (!empty($reactionData['users'])) {
foreach ($reactionData['users'] as $user) {
$userNames[] = $user['name'];
}
}
$tooltip = implode(', ', $userNames);
@endphp
<button type="button"
class="reaction-btn {{ $isActive ? 'active' : '' }}"
title="{{ $tooltip }}"
hx-post="{{ BASE_URL }}/hx/comments/reactions/toggle?commentId={{ $commentId }}"
hx-vals='{"reaction": "{{ $reactionKey }}"}'
hx-target="#reactions-{{ $commentId }}"
hx-swap="outerHTML">
<span class="reaction-emoji">{{ $emoji }}</span>
<span class="reaction-count">{{ $reactionData['reactionCount'] }}</span>
</button>
@endforeach
</span>
@endif
<span class="reaction-picker-toggle">
<button type="button" class="add-reaction-btn" onclick="toggleReactionPicker(this, {{ $commentId }})">
<i class="fa fa-smile-o"></i>
<span class="sr-only">Add reaction</span>
</button>
</span>
</span>

View File

@@ -0,0 +1,241 @@
@extends($layout)
@section('content')
@php
$comments = app()->make(Leantime\Domain\Comments\Repositories\Comments::class);
$formUrl = CURRENT_URL;
// Controller may not redirect. Make sure delComment is only added once
if (str_contains($formUrl, '?delComment=')) {
$urlParts = explode('?delComment=', $formUrl);
$deleteUrlBase = $urlParts[0] . '?delComment=';
} else {
$deleteUrlBase = $formUrl . '?delComment=';
}
@endphp
<h4 class="widgettitle title-light"><span
class="fa fa-comments"></span>{!! __('subtitles.discussion') !!}
</h4>
<form method="post" accept-charset="utf-8" action="{{ $formUrl }}"
id="commentForm">
<a href="javascript:void(0);" onclick="toggleCommentBoxes(0)"
style="display:none;" id="mainToggler"><span
class="fa fa-plus-square"></span> {!! __('links.add_new_comment') !!}
</a>
<div id="comment0" class="commentBox">
<textarea rows="5" cols="50" class="tiptapSimple"
name="text"></textarea><br/>
<input type="submit" value="{{ __('buttons.save') }}"
name="comment" class="btn btn-default btn-success"
style="margin-left: 0px;"/>
<input type="hidden" name="comment" value="1"/>
<input type="hidden" name="father" id="father" value="0"/>
<br/>
</div>
<hr/>
<div id="comments">
<div>
@foreach ($__get_comments as $row)
<div style="display:block; padding:10px; margin-top:10px; border-bottom:1px solid #f0f0f0;">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $row['userId'] }}&v={{ format($row['userModified'])->timestamp() }}"
style="float:left; width:50px; margin-right:10px; padding:2px;"/>
<div class="right">{!! sprintf(__('text.written_on'), format($row['date'])->date(), format($row['date'])->time()) !!}</div>
<strong>
{!! sprintf(__('text.full_name'), $tpl->escape($row['firstname']), $tpl->escape($row['lastname'])) !!}
</strong><br/>
<div style="margin-left:60px;">{!! $row['text'] !!}</div>
<div class="clear"></div>
<div style="padding-left:60px" class="commentLinks">
<a href="javascript:void(0);" class="replyButton"
onclick="toggleCommentBoxes({{ $row['id'] }})">
<span class="fa fa-reply"></span> {!! __('links.reply') !!}
</a>
@if ($row['userId'] == session('userdata.id'))
<a href="{{ $deleteUrlBase . $row['id'] }}"
class="deleteComment">
<span class="fa fa-trash"></span> {!! __('links.delete') !!}
</a>
@endif
<span class="comment-reactions" id="reactions-{{ $row['id'] }}"
hx-get="{{ BASE_URL }}/hx/comments/reactions/get?commentId={{ $row['id'] }}"
hx-trigger="load"
hx-swap="outerHTML">
</span>
<div style="display:none;"
id="comment{{ $row['id'] }}"
class="commentBox">
<br/><x-global::forms.button tag="input" inputType="submit"
:labelText="__('links.reply')"
name="comment" contentRole="primary" />
</div>
</div>
<div class="clear"></div>
</div>
@if ($comments->getReplies($row['id']))
@foreach ($comments->getReplies($row['id']) as $comment)
<div style="display:block; padding:10px; padding-left: 60px; border-bottom:1px solid #f0f0f0;">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $comment['userId'] }}&v={{ $comment['userModified'] }}"
style="float:left; width:50px; margin-right:10px; padding:2px;"/>
<div>
<div class="right">
{!! sprintf(__('text.written_on'), format($comment['date'])->date(), format($comment['date'])->time()) !!}
</div>
<strong>
{!! sprintf(__('text.full_name'), $tpl->escape($comment['firstname']), $tpl->escape($comment['lastname'])) !!}
</strong><br/>
<p style="margin-left:60px;">{!! nl2br($comment['text']) !!}</p>
<div class="clear"></div>
<div style="padding-left:60px" class="commentLinks">
@if ($comment['userId'] == session('userdata.id'))
<a href="{{ $deleteUrlBase . $comment['id'] }}"
class="deleteComment">
<span class="fa fa-trash"></span> {!! __('links.delete') !!}
</a>
@endif
<span class="comment-reactions" id="reactions-{{ $comment['id'] }}"
hx-get="{{ BASE_URL }}/hx/comments/reactions/get?commentId={{ $comment['id'] }}"
hx-trigger="load"
hx-swap="outerHTML">
</span>
</div>
</div>
</div>
@endforeach
@endif
@endforeach
</div>
@if (count($__get_comments) == 0)
<div class="text-center">
<div style='width:33%' class='svgContainer'>
{!! file_get_contents(ROOT . '/dist/images/svg/undraw_real_time_collaboration_c62i.svg') !!}
{{ $tpl->escape($language->__('text.no_comments')) }}
</div>
</div>
@endif
</div>
</form>
@once
@push('scripts')
<script type='text/javascript'>
// Initialize Tiptap simple editor
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
function toggleCommentBoxes(id) {
@if ($login::userIsAtLeast($roles::$commenter))
if (id == 0) {
jQuery('#mainToggler').hide();
} else {
jQuery('#mainToggler').show();
}
// Destroy existing Tiptap editors before removing textareas
jQuery('.commentBox').each(function() {
var wrapper = jQuery(this).find('.tiptap-wrapper');
if (wrapper.length && window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.registry.destroyWithin(wrapper[0]);
}
});
jQuery('.commentBox').hide('fast', function () {
// Remove both textarea and any tiptap wrapper
jQuery('.commentBox textarea').remove();
jQuery('.commentBox .tiptap-wrapper').remove();
// Create new textarea with tiptapSimple class
jQuery('#comment' + id + '').prepend('<textarea rows="5" cols="75" name="text" class="tiptapSimple"></textarea>');
// Initialize Tiptap editor on the new textarea
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
});
jQuery('#comment' + id + '').show('fast');
jQuery('#father').val(id);
@endif
}
// Reaction emoji picker - uses keys that map to the Reactions model
var reactionOptions = [
{ key: 'like', emoji: '👍' },
{ key: 'love', emoji: '❤️' },
{ key: 'celebrate', emoji: '🎉' },
{ key: 'funny', emoji: '😄' },
{ key: 'interesting', emoji: '🤔' },
{ key: 'support', emoji: '💯' }
];
var activeReactionPicker = null;
function toggleReactionPicker(btn, commentId) {
// Close any existing picker
if (activeReactionPicker) {
activeReactionPicker.remove();
activeReactionPicker = null;
}
// Create picker element
var picker = document.createElement('div');
picker.className = 'reaction-emoji-picker show';
picker.innerHTML = '<div class="reaction-emoji-picker__grid">' +
reactionOptions.map(function(r) {
return '<button type="button" class="reaction-emoji-picker__btn" ' +
'onclick="addReaction(\'' + r.key + '\', ' + commentId + ')">' +
r.emoji + '</button>';
}).join('') +
'</div>';
// Position the picker near the button
var btnRect = btn.getBoundingClientRect();
picker.style.position = 'fixed';
picker.style.left = btnRect.left + 'px';
picker.style.top = (btnRect.bottom + 5) + 'px';
document.body.appendChild(picker);
activeReactionPicker = picker;
// Close on click outside
setTimeout(function() {
document.addEventListener('click', closeReactionPicker);
}, 0);
}
function closeReactionPicker(e) {
if (activeReactionPicker && !activeReactionPicker.contains(e.target) && !e.target.classList.contains('add-reaction-btn')) {
activeReactionPicker.remove();
activeReactionPicker = null;
document.removeEventListener('click', closeReactionPicker);
}
}
function addReaction(reactionKey, commentId) {
if (activeReactionPicker) {
activeReactionPicker.remove();
activeReactionPicker = null;
}
// Make HTMX request to toggle reaction
htmx.ajax('POST', '{{ BASE_URL }}/hx/comments/reactions/toggle?commentId=' + commentId, {
values: { reaction: reactionKey },
target: '#reactions-' + commentId,
swap: 'outerHTML'
});
}
</script>
@endpush
@endonce
@endsection

View File

@@ -0,0 +1,330 @@
@php
// Repository is used downstream for getReplies(). Renamed from
// $comments because the controller already assigns $comments to
// the array of comments to render — overwriting it with the
// repository object made the @foreach below iterate the object's
// (empty) public properties instead of the array, so every
// Discussion section came up empty regardless of how the comment
// was added.
$commentsRepo = app()->make(Leantime\Domain\Comments\Repositories\Comments::class);
$formUrl = CURRENT_URL;
$formHash = md5($formUrl);
// Controller may not redirect. Make sure delComment is only added once
if (str_contains($formUrl, '?delComment=')) {
$urlParts = explode('?delComment=', $formUrl);
$deleteUrlBase = $urlParts[0] . '?delComment=';
} else {
$deleteUrlBase = $formUrl . '?delComment=';
}
@endphp
<form method="post" accept-charset="utf-8" action="{{ $formUrl }}" id="commentForm-{{ $formHash }}" class="formModal">
@if ($login::userIsAtLeast($roles::$commenter))
<div class="mainToggler-{{ $formHash }}" id="">
<div class="commentImage">
<img src="{{ BASE_URL }}/api/users?profileImage={{ session('userdata.id') }}&v={{ format(session('userdata.modified'))->timestamp() }}" />
</div>
<div class="commentReply inactive">
<a href="javascript:void(0);" onclick="toggleCommentBoxes(0, null, '{{ $formHash }}')">
{!! __('links.add_new_comment') !!}
</a>
</div>
</div>
<div id="comment-{{ $formHash }}-0" class="commentBox-{{ $formHash }} commenterFields" style="display:none;">
<div class="commentImage">
<img src="{{ BASE_URL }}/api/users?profileImage={{ session('userdata.id') }}&v={{ format(session('userdata.modified'))->timestamp() }}" />
</div>
<div class="commentReply">
<textarea rows="5" cols="50" class="tiptapSimple" name="text"></textarea>
<input type="submit" value="{{ __('buttons.save') }}" name="comment" class="btn btn-primary btn-success" style="margin-left: 0px;"/>
</div>
<input type="hidden" name="comment" class="commenterField" value="1"/>
<input type="hidden" name="father" class="commenterField" id="father-{{ $formHash }}" value="0"/>
<input type="hidden" name="edit-comment-helper" class="commenterField" id="edit-comment-helper-{{ $formHash }}" />
<br/>
</div>
@endif
<div id="comments-{{ $formHash }}">
<div>
@foreach ($comments as $row)
<div class="clearall">
<div class="commentImage" id="comment-image-to-hide-on-edit-{{ $formHash }}-{{ $row['id'] }}">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $row['userId'] }}&v={{ format($row['userModified'])->timestamp() }}"/>
</div>
<div class="commentMain">
<div class="commentContent" id="comment-to-hide-on-edit-{{ $formHash }}-{{ $row['id'] }}">
<div class="right commentDate">
{!! sprintf(__('text.written_on'), format($row['date'])->date(), format($row['date'])->time()) !!}
@if ($login::userIsAtLeast($roles::$editor))
<div class="inlineDropDownContainer" style="float:right; margin-left:10px;">
<a href="javascript:void(0);" class="dropdown-toggle ticketDropDown" data-toggle="dropdown">
<i class="fa fa-ellipsis-v" aria-hidden="true"></i>
</a>
<ul class="dropdown-menu">
@if (($row['userId'] == session('userdata.id')) || can('comments.moderate'))
<li><a href="{{ $deleteUrlBase . $row['id'] }}" class="deleteComment formModal">
<span class="fa fa-trash"></span> {!! __('links.delete') !!}
</a></li>
@endif
@if (($row['userId'] == session('userdata.id')) || can('comments.moderate'))
<li>
<a href="javascript:void(0);" onclick="toggleCommentBoxes({{ $row['id'] }}, null, '{{ $formHash }}', true)">
<span class="fa fa-edit"></span> {!! __('label.edit') !!}
</a>
</li>
@endif
@if (isset($ticket->id))
<li><a href="javascript:void(0);" onclick="leantime.ticketsController.addCommentTimesheetContent({{ $row['id'] }}, {{ $ticket->id }});">{!! __('links.add_to_timesheets') !!}</a></li>
@endif
</ul>
</div>
@endif
</div>
<span class="name">{!! sprintf(__('text.full_name'), $tpl->escape($row['firstname']), $tpl->escape($row['lastname'])) !!}</span>
<div class="text tiptap-content" id="commentText-{{ $formHash }}-{{ $row['id'] }}">
<div id="comment-text-to-hide-{{ $formHash }}-{{ $row['id'] }}">{!! $tpl->escapeMinimal($row['text']) !!}</div>
</div>
</div>
<div class="commentLinks" id="comment-link-to-hide-on-edit-{{ $formHash }}-{{ $row['id'] }}">
@if ($login::userIsAtLeast($roles::$commenter))
<a href="javascript:void(0);"
onclick="toggleCommentBoxes({{ $row['id'] }}, null, '{{ $formHash }}')">
<span class="fa fa-reply"></span> {!! __('links.reply') !!}
</a>
@endif
<span class="comment-reactions" id="reactions-{{ $row['id'] }}"
hx-get="{{ BASE_URL }}/hx/comments/reactions/get?commentId={{ $row['id'] }}"
hx-trigger="load"
hx-swap="outerHTML">
</span>
</div>
{{-- Reply/edit box for the parent comment. Kept ABOVE the replies thread so
editing a comment that has replies opens the editor in place rather than
jumping below its replies. (#3319) --}}
<div style="display:none;" id="comment-{{ $formHash }}-{{ $row['id'] }}" class="commentBox">
<div class="commentImage">
<img src="{{ BASE_URL }}/api/users?profileImage={{ session('userdata.id') }}&v={{ format(session('userdata.modified'))->timestamp() }}"/>
</div>
<div class="commentReply">
<x-global::forms.button tag="input" inputType="submit" :labelText="__('links.reply')" name="comment" id="submit-reply-button" contentRole="primary" />
<x-global::forms.button tag="input" inputType="button" onclick="cancel({{ $row['id'] }}, '{{ $formHash }}')" :labelText="__('links.cancel')" contentRole="tertiary" />
</div>
<div class="clearall"></div>
</div>
<div class="replies">
@if ($commentsRepo->getReplies($row['id']))
@foreach ($commentsRepo->getReplies($row['id']) as $comment)
<div>
<div class="commentImage">
<img src="{{ BASE_URL }}/api/users?profileImage={{ $comment['userId'] }}&v={{ format($comment['userModified'])->timestamp() }}"/>
</div>
<div class="commentMain">
<div class="commentContent">
<div class="right commentDate">
{!! sprintf(__('text.written_on'), format($comment['date'])->date(), format($comment['date'])->time()) !!}
</div>
<span class="name">{!! sprintf(__('text.full_name'), $tpl->escape($comment['firstname']), $tpl->escape($comment['lastname'])) !!}</span>
<div class="text tiptap-content" id="comment-text-to-hide-reply-{{ $formHash }}-{{ $comment['id'] }}">{!! $tpl->escapeMinimal($comment['text']) !!}</div>
</div>
<div class="commentLinks">
@if ($login::userIsAtLeast($roles::$commenter))
<a href="javascript:void(0);"
onclick="toggleCommentBoxes({{ $row['id'] }}, null, '{{ $formHash }}')">
<span class="fa fa-reply"></span> {!! __('links.reply') !!}
</a>
@if ($comment['userId'] == session('userdata.id'))
<a href="{{ $deleteUrlBase . $comment['id'] }}"
class="deleteComment formModal">
<span class="fa fa-trash"></span> {!! __('links.delete') !!}
</a>
<a href="javascript:void(0);" onclick="toggleCommentBoxes({{ $row['id'] }}, {{ $comment['id'] }}, '{{ $formHash }}', true, true)">
<span class="fa fa-edit"></span> {!! __('label.edit') !!}
</a>
@endif
@endif
<span class="comment-reactions" id="reactions-{{ $comment['id'] }}"
hx-get="{{ BASE_URL }}/hx/comments/reactions/get?commentId={{ $comment['id'] }}"
hx-trigger="load"
hx-swap="outerHTML">
</span>
</div>
</div>
<div class="clearall"></div>
</div>
@endforeach
@endif
</div>
</div>
</div>
@endforeach
</div>
</div>
<div class="clearall"></div>
</form>
<script type='text/javascript'>
jQuery(document).ready(function() {
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
}
});
function toggleCommentBoxes(id, commentId, formHash, editComment = false, isReply = false) {
@if ($login::userIsAtLeast($roles::$commenter))
if (parseInt(id, 10) === 0) {
jQuery(`.mainToggler-${formHash}`).hide();
} else {
jQuery(`.mainToggler-${formHash}`).show();
}
if (editComment) {
jQuery(`#comment-to-hide-on-edit-${formHash}-${id}`).hide();
jQuery(`#comment-link-to-hide-on-edit-${formHash}-${id}`).hide();
jQuery(`#comment-image-to-hide-on-edit-${formHash}-${id}`).hide();
jQuery(`#edit-comment-helper-${formHash}`).val(commentId || id);
jQuery('#submit-reply-button').val('{{ __('buttons.save') }}');
}
// Destroy existing Tiptap editors before removing textareas
jQuery(`.commentBox-${formHash}`).each(function() {
var wrapper = jQuery(this).find('.tiptap-wrapper');
if (wrapper.length && window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.registry.destroyWithin(wrapper[0]);
}
});
jQuery(`.commentBox-${formHash} textarea`).remove();
jQuery(`.commentBox-${formHash} .tiptap-wrapper`).remove();
jQuery(`.commentBox-${formHash}`).hide();
// Create textarea with tiptapSimple class
var initialContent = editComment ? jQuery(`#comment-text-to-hide-${isReply ? 'reply-' : ''}${formHash}-${commentId || id}`).html() : '';
jQuery(`#comment-${formHash}-${id} .commentReply`).prepend(`<textarea rows="5" cols="75" name="text" id="editor_${formHash}-${id}" class="tiptapSimple">${initialContent}</textarea>`);
// Initialize Tiptap editor
if (window.leantime && window.leantime.tiptapController) {
leantime.tiptapController.initSimpleEditor();
// Focus the editor after a short delay to allow initialization
setTimeout(function() {
var editorEl = document.querySelector(`#comment-${formHash}-${id} .tiptap-editor`);
if (editorEl) {
var editor = leantime.tiptapController.registry.get(editorEl);
if (editor) {
editor.commands.focus('end');
}
}
}, 100);
}
jQuery(`#comment-${formHash}-${id}`).show();
jQuery(`#father-${formHash}`).val(id);
@endif
}
function cancel(id, formHash) {
@if ($login::userIsAtLeast($roles::$commenter))
jQuery(`#comment-to-hide-on-edit-${formHash}-${id}`).show();
jQuery(`.commentBox-${formHash} textarea`).remove();
jQuery(`#comment-link-to-hide-on-edit-${formHash}-${id}`).show();
jQuery(`#comment-image-to-hide-on-edit-${formHash}-${id}`).show();
jQuery(`#comment-${formHash}-${id}`).hide();
@endif
}
jQuery(".confetti").click(function(){
confetti({
spread: 70,
origin: { y: 1.2 },
});
});
function respondToVisibility(element, callback) {
var options = {
root: document.documentElement,
};
var observer = new IntersectionObserver((entries, observer) => {
entries.forEach(entry => {
callback(entry.intersectionRatio > 0);
});
}, options);
observer.observe(element);
}
// Reaction emoji picker - uses keys that map to the Reactions model
var reactionOptions = [
{ key: 'like', emoji: '👍' },
{ key: 'love', emoji: '❤️' },
{ key: 'celebrate', emoji: '🎉' },
{ key: 'funny', emoji: '😄' },
{ key: 'interesting', emoji: '🤔' },
{ key: 'support', emoji: '💯' }
];
var activeReactionPicker = null;
function toggleReactionPicker(btn, commentId) {
// Close any existing picker
if (activeReactionPicker) {
activeReactionPicker.remove();
activeReactionPicker = null;
}
// Create picker element
var picker = document.createElement('div');
picker.className = 'reaction-emoji-picker show';
picker.innerHTML = '<div class="reaction-emoji-picker__grid">' +
reactionOptions.map(function(r) {
return '<button type="button" class="reaction-emoji-picker__btn" ' +
'onclick="addReaction(\'' + r.key + '\', ' + commentId + ')">' +
r.emoji + '</button>';
}).join('') +
'</div>';
// Position the picker near the button
var btnRect = btn.getBoundingClientRect();
picker.style.position = 'fixed';
picker.style.left = btnRect.left + 'px';
picker.style.top = (btnRect.bottom + 5) + 'px';
document.body.appendChild(picker);
activeReactionPicker = picker;
// Close on click outside
setTimeout(function() {
document.addEventListener('click', closeReactionPicker);
}, 0);
}
function closeReactionPicker(e) {
if (activeReactionPicker && !activeReactionPicker.contains(e.target) && !e.target.classList.contains('add-reaction-btn')) {
activeReactionPicker.remove();
activeReactionPicker = null;
document.removeEventListener('click', closeReactionPicker);
}
}
function addReaction(reactionKey, commentId) {
if (activeReactionPicker) {
activeReactionPicker.remove();
activeReactionPicker = null;
}
// Make HTMX request to toggle reaction
htmx.ajax('POST', '{{ BASE_URL }}/hx/comments/reactions/toggle?commentId=' + commentId, {
values: { reaction: reactionKey },
target: '#reactions-' + commentId,
swap: 'outerHTML'
});
}
</script>

View File

@@ -0,0 +1,87 @@
<?php
namespace Leantime\Domain\Comments\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Comments\Services\Comments;
use Leantime\Domain\Goalcanvas\Services\Goalcanvas;
use Leantime\Domain\Projects\Services\Projects;
use Leantime\Domain\Tickets\Services\Tickets;
/**
* Add a new comment to a specific entity.
*/
class AddCommentTool extends Tool
{
public function __construct(
private Comments $commentsService,
private Projects $projectService,
private Tickets $ticketService,
private Goalcanvas $goalcanvasService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('text')->description('Comment text.')
->required()
->string('module')->description('Module type (ticket, project, goal, etc.).')
->required()
->integer('entityId')->description('ID of the entity to add comment to.')
->required()
->string('status')->description('Status indicator for project updates (green, yellow, red). Only used for project comments.');
}
public function name(): string
{
return 'addComment';
}
public function description(): string
{
return 'Adds a new comment to a specific entity.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$module = $arguments['module'];
$entityId = (int) ($arguments['entityId'] ?? 0);
$entity = $this->getEntity($module, $entityId);
if (! $entity) {
return ToolResult::error("Entity not found: {$module} ID {$entityId}");
}
$values = [
'text' => $arguments['text'],
'father' => 0,
'status' => ($arguments['status'] ?? ''),
];
$result = $this->commentsService->addComment($values, $module, $entityId, $entity);
if ($result) {
return ToolResult::text("Comment added successfully to {$module} #{$entityId}");
}
return ToolResult::error('Failed to add comment. Please check the provided information.');
}
/**
* Helper method to get an entity based on module type and ID.
*/
private function getEntity(string $module, int $entityId): mixed
{
return match ($module) {
'ticket' => $this->ticketService->getTicket($entityId),
'project' => $this->projectService->getProject($entityId),
'goal', 'goalcanvas', 'goalcanvasitem' => $this->goalcanvasService->getSingleCanvas($entityId),
default => ['id' => $entityId],
};
}
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Leantime\Domain\Comments\Tools;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Comments\Services\Comments;
use Leantime\Domain\Projects\Services\Projects;
/**
* Add a project status update with a red/yellow/green indicator.
*/
class AddProjectStatusUpdateTool extends Tool
{
public function __construct(
private Comments $commentsService,
private Projects $projectService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to add status update to.')
->required()
->string('text')->description('Status update text.')
->required()
->string('status')
->description('Status indicator (green, yellow, red).')
->required();
}
public function name(): string
{
return 'addProjectStatusUpdate';
}
public function description(): string
{
return 'Adds a new status update to a project with a red/yellow/green indicator.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$status = $arguments['status'];
if (! in_array($status, ['green', 'yellow', 'red'])) {
return ToolResult::error("Invalid status value. Must be 'green', 'yellow', or 'red'.");
}
$project = $this->projectService->getProject($projectId);
if (! $project) {
return ToolResult::error("Project not found: ID {$projectId}");
}
$values = [
'text' => $arguments['text'],
'father' => 0,
'status' => $status,
];
$result = $this->commentsService->addComment($values, 'project', $projectId, $project);
if ($result) {
return ToolResult::text("Project status update added successfully with status: {$status}");
}
return ToolResult::error('Failed to add status update. Please check the provided information.');
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Leantime\Domain\Comments\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Comments\Services\Comments;
/**
* Get all project status updates (comments) for a specific project.
*/
#[IsReadOnly]
class GetAllProjectCommentsTool extends Tool
{
public function __construct(
private Comments $commentsService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to get status updates for.')
->required();
}
public function name(): string
{
return 'getAllProjectComments';
}
public function description(): string
{
return 'Gets all project status updates for a specific project.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = (int) ($arguments['projectId'] ?? 0);
$comments = $this->commentsService->getComments('project', $projectId);
if (empty($comments)) {
return ToolResult::text("No status updates found for project ID: {$projectId}");
}
$response = "## Project Status Updates\n";
foreach ($comments as $comment) {
$statusIndicator = match ($comment['status']) {
'green' => '🟢 ',
'yellow' => '🟡 ',
'red' => '🔴 ',
default => '',
};
$result = [
'id' => $comment['id'],
'status' => $statusIndicator.($comment['status'] ?: 'None'),
'text' => Str::sanitizeForLLM($comment['text']),
'date' => $comment['date'],
'author' => $comment['firstname'].' '.$comment['lastname'],
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace Leantime\Domain\Comments\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Comments\Services\Comments;
/**
* Get all comments for a specific entity.
*/
#[IsReadOnly]
class GetCommentsTool extends Tool
{
public function __construct(
private Comments $commentsService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->string('module')->description('Module type (ticket, project, goal, etc.).')
->required()
->integer('entityId')->description('ID of the entity to get comments for.')
->required()
->integer('commentOrder')->description('Order of comments (0 = newest first, 1 = oldest first).');
}
public function name(): string
{
return 'getComments';
}
public function description(): string
{
return 'Gets all comments for a specific entity.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$module = $arguments['module'];
$entityId = (int) ($arguments['entityId'] ?? 0);
$commentOrder = (int) ($arguments['commentOrder'] ?? 0);
$comments = $this->commentsService->getComments($module, $entityId, $commentOrder);
if (empty($comments)) {
return ToolResult::text("No comments found for {$module} ID: {$entityId}");
}
$response = "## Comments for {$module} #{$entityId}\n";
foreach ($comments as $comment) {
$result = [
'id' => $comment['id'],
'text' => Str::sanitizeForLLM($comment['text']),
'date' => $comment['date'],
'userId' => $comment['userId'],
'author' => $comment['firstname'].' '.$comment['lastname'],
'status' => $comment['status'] ?: 'None',
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Leantime\Domain\Comments\Tools;
use Illuminate\Support\Str;
use Laravel\Mcp\Server\Tool;
use Laravel\Mcp\Server\Tools\Annotations\IsReadOnly;
use Laravel\Mcp\Server\Tools\ToolInputSchema;
use Laravel\Mcp\Server\Tools\ToolResult;
use Leantime\Domain\Comments\Services\Comments;
/**
* Poll for all comments across the account.
*/
#[IsReadOnly]
class PollCommentsTool extends Tool
{
public function __construct(
private Comments $commentsService,
) {}
public function schema(ToolInputSchema $schema): ToolInputSchema
{
return $schema
->integer('projectId')->description('Project ID to filter comments by.')
->integer('moduleId')->description('Module ID to filter comments by.');
}
public function name(): string
{
return 'pollComments';
}
public function description(): string
{
return 'Polls for all comments across the account.';
}
/**
* Handle the tool request.
*/
public function handle(array $arguments): ToolResult
{
$projectId = ($arguments['projectId'] ?? null);
$moduleId = ($arguments['moduleId'] ?? null);
$comments = $this->commentsService->pollComments($projectId, $moduleId);
if (empty($comments)) {
return ToolResult::text('No comments found');
}
$response = "## Comments\n";
foreach ($comments as $comment) {
$statusIndicator = '';
if (isset($comment['status'])) {
$statusIndicator = match ($comment['status']) {
'green' => '🟢 ',
'yellow' => '🟡 ',
'red' => '🔴 ',
default => '',
};
}
$result = [
'id' => $comment['id'],
'module' => $comment['module'],
'moduleId' => $comment['moduleId'],
'status' => $comment['status'] ? $statusIndicator.$comment['status'] : 'None',
'text' => Str::sanitizeForLLM($comment['text']),
'date' => $comment['date'],
'projectId' => $comment['projectId'],
];
$response .= Str::toMarkdown($result)."\n";
}
return ToolResult::text($response);
}
}