OneBot: Leantime 改造版源码(BOM/Univer 表格/AI 接管/品牌替换等)
This commit is contained in:
82
app/Domain/Comments/Controllers/ShowAll.php
Normal file
82
app/Domain/Comments/Controllers/ShowAll.php
Normal 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;
|
||||
}
|
||||
}
|
||||
86
app/Domain/Comments/Hxcontrollers/Reactions.php
Normal file
86
app/Domain/Comments/Hxcontrollers/Reactions.php
Normal 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', []);
|
||||
}
|
||||
}
|
||||
74
app/Domain/Comments/Js/commentsController.js
Normal file
74
app/Domain/Comments/Js/commentsController.js
Normal 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
|
||||
};
|
||||
|
||||
})();
|
||||
39
app/Domain/Comments/Permissions/CommentsPermissions.php
Normal file
39
app/Domain/Comments/Permissions/CommentsPermissions.php
Normal 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'),
|
||||
];
|
||||
}
|
||||
}
|
||||
252
app/Domain/Comments/Repositories/Comments.php
Normal file
252
app/Domain/Comments/Repositories/Comments.php
Normal 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());
|
||||
}
|
||||
}
|
||||
407
app/Domain/Comments/Services/Comments.php
Normal file
407
app/Domain/Comments/Services/Comments.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
9
app/Domain/Comments/Templates/components/input.blade.php
Normal file
9
app/Domain/Comments/Templates/components/input.blade.php
Normal 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>
|
||||
41
app/Domain/Comments/Templates/components/reply.blade.php
Normal file
41
app/Domain/Comments/Templates/components/reply.blade.php
Normal 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>
|
||||
56
app/Domain/Comments/Templates/partials/reactions.blade.php
Normal file
56
app/Domain/Comments/Templates/partials/reactions.blade.php
Normal 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>
|
||||
241
app/Domain/Comments/Templates/showAll.blade.php
Normal file
241
app/Domain/Comments/Templates/showAll.blade.php
Normal 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
|
||||
@@ -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>
|
||||
87
app/Domain/Comments/Tools/AddCommentTool.php
Normal file
87
app/Domain/Comments/Tools/AddCommentTool.php
Normal 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],
|
||||
};
|
||||
}
|
||||
}
|
||||
74
app/Domain/Comments/Tools/AddProjectStatusUpdateTool.php
Normal file
74
app/Domain/Comments/Tools/AddProjectStatusUpdateTool.php
Normal 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.');
|
||||
}
|
||||
}
|
||||
72
app/Domain/Comments/Tools/GetAllProjectCommentsTool.php
Normal file
72
app/Domain/Comments/Tools/GetAllProjectCommentsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
72
app/Domain/Comments/Tools/GetCommentsTool.php
Normal file
72
app/Domain/Comments/Tools/GetCommentsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
79
app/Domain/Comments/Tools/PollCommentsTool.php
Normal file
79
app/Domain/Comments/Tools/PollCommentsTool.php
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user