Skip to content

Commit 07de6ec

Browse files
authored
Merge pull request #4286 from BookStackApp/comment_threads
Comment threads
2 parents 88785aa + 19e39dd commit 07de6ec

File tree

15 files changed

+463
-270
lines changed

15 files changed

+463
-270
lines changed

app/Activity/CommentRepo.php

Lines changed: 5 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -7,27 +7,14 @@
77
use BookStack\Facades\Activity as ActivityService;
88
use League\CommonMark\CommonMarkConverter;
99

10-
/**
11-
* Class CommentRepo.
12-
*/
1310
class CommentRepo
1411
{
15-
/**
16-
* @var Comment
17-
*/
18-
protected $comment;
19-
20-
public function __construct(Comment $comment)
21-
{
22-
$this->comment = $comment;
23-
}
24-
2512
/**
2613
* Get a comment by ID.
2714
*/
2815
public function getById(int $id): Comment
2916
{
30-
return $this->comment->newQuery()->findOrFail($id);
17+
return Comment::query()->findOrFail($id);
3118
}
3219

3320
/**
@@ -36,7 +23,7 @@ public function getById(int $id): Comment
3623
public function create(Entity $entity, string $text, ?int $parent_id): Comment
3724
{
3825
$userId = user()->id;
39-
$comment = $this->comment->newInstance();
26+
$comment = new Comment();
4027

4128
$comment->text = $text;
4229
$comment->html = $this->commentToHtml($text);
@@ -83,17 +70,16 @@ public function commentToHtml(string $commentText): string
8370
'allow_unsafe_links' => false,
8471
]);
8572

86-
return $converter->convertToHtml($commentText);
73+
return $converter->convert($commentText);
8774
}
8875

8976
/**
9077
* Get the next local ID relative to the linked entity.
9178
*/
9279
protected function getNextLocalId(Entity $entity): int
9380
{
94-
/** @var Comment $comment */
95-
$comment = $entity->comments(false)->orderBy('local_id', 'desc')->first();
81+
$currentMaxId = $entity->comments()->max('local_id');
9682

97-
return ($comment->local_id ?? 0) + 1;
83+
return $currentMaxId + 1;
9884
}
9985
}

app/Activity/Controllers/CommentController.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,9 @@
1010

1111
class CommentController extends Controller
1212
{
13-
protected $commentRepo;
14-
15-
public function __construct(CommentRepo $commentRepo)
16-
{
17-
$this->commentRepo = $commentRepo;
13+
public function __construct(
14+
protected CommentRepo $commentRepo
15+
) {
1816
}
1917

2018
/**
@@ -43,7 +41,12 @@ public function savePageComment(Request $request, int $pageId)
4341
$this->checkPermission('comment-create-all');
4442
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
4543

46-
return view('comments.comment', ['comment' => $comment]);
44+
return view('comments.comment-branch', [
45+
'branch' => [
46+
'comment' => $comment,
47+
'children' => [],
48+
]
49+
]);
4750
}
4851

4952
/**

app/Activity/Tools/CommentTree.php

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace BookStack\Activity\Tools;
4+
5+
use BookStack\Activity\Models\Comment;
6+
use BookStack\Entities\Models\Page;
7+
8+
class CommentTree
9+
{
10+
/**
11+
* The built nested tree structure array.
12+
* @var array{comment: Comment, depth: int, children: array}[]
13+
*/
14+
protected array $tree;
15+
protected array $comments;
16+
17+
public function __construct(
18+
protected Page $page
19+
) {
20+
$this->comments = $this->loadComments();
21+
$this->tree = $this->createTree($this->comments);
22+
}
23+
24+
public function enabled(): bool
25+
{
26+
return !setting('app-disable-comments');
27+
}
28+
29+
public function empty(): bool
30+
{
31+
return count($this->tree) === 0;
32+
}
33+
34+
public function count(): int
35+
{
36+
return count($this->comments);
37+
}
38+
39+
public function get(): array
40+
{
41+
return $this->tree;
42+
}
43+
44+
/**
45+
* @param Comment[] $comments
46+
*/
47+
protected function createTree(array $comments): array
48+
{
49+
$byId = [];
50+
foreach ($comments as $comment) {
51+
$byId[$comment->local_id] = $comment;
52+
}
53+
54+
$childMap = [];
55+
foreach ($comments as $comment) {
56+
$parent = $comment->parent_id;
57+
if (is_null($parent) || !isset($byId[$parent])) {
58+
$parent = 0;
59+
}
60+
61+
if (!isset($childMap[$parent])) {
62+
$childMap[$parent] = [];
63+
}
64+
$childMap[$parent][] = $comment->local_id;
65+
}
66+
67+
$tree = [];
68+
foreach ($childMap[0] as $childId) {
69+
$tree[] = $this->createTreeForId($childId, 0, $byId, $childMap);
70+
}
71+
72+
return $tree;
73+
}
74+
75+
protected function createTreeForId(int $id, int $depth, array &$byId, array &$childMap): array
76+
{
77+
$childIds = $childMap[$id] ?? [];
78+
$children = [];
79+
80+
foreach ($childIds as $childId) {
81+
$children[] = $this->createTreeForId($childId, $depth + 1, $byId, $childMap);
82+
}
83+
84+
return [
85+
'comment' => $byId[$id],
86+
'depth' => $depth,
87+
'children' => $children,
88+
];
89+
}
90+
91+
protected function loadComments(): array
92+
{
93+
if (!$this->enabled()) {
94+
return [];
95+
}
96+
97+
return $this->page->comments()
98+
->with('createdBy')
99+
->get()
100+
->all();
101+
}
102+
}

app/Entities/Controllers/PageController.php

Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace BookStack\Entities\Controllers;
44

55
use BookStack\Activity\Models\View;
6+
use BookStack\Activity\Tools\CommentTree;
67
use BookStack\Entities\Models\Page;
78
use BookStack\Entities\Repos\PageRepo;
89
use BookStack\Entities\Tools\BookContents;
@@ -140,15 +141,10 @@ public function show(string $bookSlug, string $pageSlug)
140141

141142
$pageContent = (new PageContent($page));
142143
$page->html = $pageContent->render();
143-
$sidebarTree = (new BookContents($page->book))->getTree();
144144
$pageNav = $pageContent->getNavigation($page->html);
145145

146-
// Check if page comments are enabled
147-
$commentsEnabled = !setting('app-disable-comments');
148-
if ($commentsEnabled) {
149-
$page->load(['comments.createdBy']);
150-
}
151-
146+
$sidebarTree = (new BookContents($page->book))->getTree();
147+
$commentTree = (new CommentTree($page));
152148
$nextPreviousLocator = new NextPreviousContentLocator($page, $sidebarTree);
153149

154150
View::incrementFor($page);
@@ -159,7 +155,7 @@ public function show(string $bookSlug, string $pageSlug)
159155
'book' => $page->book,
160156
'current' => $page,
161157
'sidebarTree' => $sidebarTree,
162-
'commentsEnabled' => $commentsEnabled,
158+
'commentTree' => $commentTree,
163159
'pageNav' => $pageNav,
164160
'next' => $nextPreviousLocator->getNext(),
165161
'previous' => $nextPreviousLocator->getPrevious(),

lang/en/entities.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -362,11 +362,10 @@
362362
'comment_placeholder' => 'Leave a comment here',
363363
'comment_count' => '{0} No Comments|{1} 1 Comment|[2,*] :count Comments',
364364
'comment_save' => 'Save Comment',
365-
'comment_saving' => 'Saving comment...',
366-
'comment_deleting' => 'Deleting comment...',
367365
'comment_new' => 'New Comment',
368366
'comment_created' => 'commented :createDiff',
369367
'comment_updated' => 'Updated :updateDiff by :username',
368+
'comment_updated_indicator' => 'Updated',
370369
'comment_deleted_success' => 'Comment deleted',
371370
'comment_created_success' => 'Comment added',
372371
'comment_updated_success' => 'Comment updated',

resources/js/components/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export {MarkdownEditor} from './markdown-editor';
3434
export {NewUserPassword} from './new-user-password';
3535
export {Notification} from './notification';
3636
export {OptionalInput} from './optional-input';
37+
export {PageComment} from './page-comment';
3738
export {PageComments} from './page-comments';
3839
export {PageDisplay} from './page-display';
3940
export {PageEditor} from './page-editor';
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import {Component} from './component';
2+
import {getLoading, htmlToDom} from '../services/dom';
3+
4+
export class PageComment extends Component {
5+
6+
setup() {
7+
// Options
8+
this.commentId = this.$opts.commentId;
9+
this.commentLocalId = this.$opts.commentLocalId;
10+
this.commentParentId = this.$opts.commentParentId;
11+
this.deletedText = this.$opts.deletedText;
12+
this.updatedText = this.$opts.updatedText;
13+
14+
// Element References
15+
this.container = this.$el;
16+
this.contentContainer = this.$refs.contentContainer;
17+
this.form = this.$refs.form;
18+
this.formCancel = this.$refs.formCancel;
19+
this.editButton = this.$refs.editButton;
20+
this.deleteButton = this.$refs.deleteButton;
21+
this.replyButton = this.$refs.replyButton;
22+
this.input = this.$refs.input;
23+
24+
this.setupListeners();
25+
}
26+
27+
setupListeners() {
28+
if (this.replyButton) {
29+
this.replyButton.addEventListener('click', () => this.$emit('reply', {
30+
id: this.commentLocalId,
31+
element: this.container,
32+
}));
33+
}
34+
35+
if (this.editButton) {
36+
this.editButton.addEventListener('click', this.startEdit.bind(this));
37+
this.form.addEventListener('submit', this.update.bind(this));
38+
this.formCancel.addEventListener('click', () => this.toggleEditMode(false));
39+
}
40+
41+
if (this.deleteButton) {
42+
this.deleteButton.addEventListener('click', this.delete.bind(this));
43+
}
44+
}
45+
46+
toggleEditMode(show) {
47+
this.contentContainer.toggleAttribute('hidden', show);
48+
this.form.toggleAttribute('hidden', !show);
49+
}
50+
51+
startEdit() {
52+
this.toggleEditMode(true);
53+
const lineCount = this.$refs.input.value.split('\n').length;
54+
this.$refs.input.style.height = `${(lineCount * 20) + 40}px`;
55+
}
56+
57+
async update(event) {
58+
event.preventDefault();
59+
const loading = this.showLoading();
60+
this.form.toggleAttribute('hidden', true);
61+
62+
const reqData = {
63+
text: this.input.value,
64+
parent_id: this.parentId || null,
65+
};
66+
67+
try {
68+
const resp = await window.$http.put(`/comment/${this.commentId}`, reqData);
69+
const newComment = htmlToDom(resp.data);
70+
this.container.replaceWith(newComment);
71+
window.$events.success(this.updatedText);
72+
} catch (err) {
73+
console.error(err);
74+
window.$events.showValidationErrors(err);
75+
this.form.toggleAttribute('hidden', false);
76+
loading.remove();
77+
}
78+
}
79+
80+
async delete() {
81+
this.showLoading();
82+
83+
await window.$http.delete(`/comment/${this.commentId}`);
84+
this.container.closest('.comment-branch').remove();
85+
window.$events.success(this.deletedText);
86+
this.$emit('delete');
87+
}
88+
89+
showLoading() {
90+
const loading = getLoading();
91+
loading.classList.add('px-l');
92+
this.container.append(loading);
93+
return loading;
94+
}
95+
96+
}

0 commit comments

Comments
 (0)