1<?php
2
3// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
4// See the LICENCE file in the repository root for full licence text.
5
6namespace App\Libraries;
7
8use App\Models\Comment;
9use App\Models\CommentVote;
10use App\Models\User;
11use Ds\Set;
12use Illuminate\Database\Eloquent\Collection;
13
14class CommentBundle
15{
16 public int $depth;
17 public bool $includeDeleted;
18 public bool $includePinned;
19 public CommentBundleParams $params;
20
21 private ?Comment $comment;
22 private ?User $user;
23
24 public static function forComment(Comment $comment, bool $includeNested = false)
25 {
26 $options = ['comment' => $comment];
27
28 if ($includeNested) {
29 $options['params'] = ['parent_id' => $comment->getKey()];
30 }
31
32 return new static($comment->commentable, $options);
33 }
34
35 public static function forEmbed($commentable)
36 {
37 return new static($commentable, ['params' => ['parent_id' => 0]]);
38 }
39
40
41 public function __construct(private ?Commentable $commentable, array $options = [])
42 {
43 $this->user = auth()->user();
44
45 $this->params = new CommentBundleParams($options['params'] ?? [], $this->user);
46
47 $this->comment = $options['comment'] ?? null;
48 $this->depth = $options['depth'] ?? 2;
49 $this->includeDeleted = isset($commentable);
50 $this->includePinned = isset($commentable);
51 }
52
53 public function toArray()
54 {
55 $hasMore = false;
56 $includedComments = collect();
57 $pinnedComments = collect();
58
59 // Either use the provided comment as a base, or look for matching comments.
60 if (isset($this->comment)) {
61 $comments = new Collection([$this->comment]);
62 if ($this->comment->parent !== null) {
63 $includedComments->push($this->comment->parent);
64 }
65 } else {
66 $comments = $this->getComments($this->commentsQuery(), false);
67 if ($comments->count() > $this->params->limit) {
68 $hasMore = true;
69 $comments->pop();
70 }
71 }
72
73 // Get parents when listing comments index or loading comment replies
74 if ($this->commentable === null || $this->params->parentId !== null) {
75 $parentIds = array_reject_null($comments->pluck('parent_id'));
76 if (count($parentIds) > 0) {
77 $parents = $this->getComments(Comment::whereIn('id', $parentIds));
78 $includedComments = $includedComments->concat($parents);
79 }
80 }
81
82 $commentIds = new Set($comments->pluck('id'));
83
84 // Get nested comments
85 if ($this->params->parentId !== null) {
86 $nestedParentIds = $commentIds->toArray();
87
88 for ($i = 0; $i < $this->depth; $i++) {
89 $nestedComments = $this->getComments(Comment::whereIn('parent_id', $nestedParentIds));
90 $includedComments = $includedComments->concat($nestedComments);
91 $nestedParentIds = array_reject_null($nestedComments->pluck('id'));
92 if (count($nestedParentIds) === 0) {
93 break;
94 }
95 }
96 }
97
98 $includedComments = $includedComments
99 ->unique('id', true)
100 ->reject(fn ($comment) => $commentIds->contains($comment->getKey()));
101
102 if ($this->includePinned) {
103 $pinnedComments = $this->getComments($this->commentsQuery()->where('pinned', true), true, true);
104 }
105
106 $allComments = $comments->concat($includedComments)->concat($pinnedComments);
107 $allComments->load('commentable');
108
109 $result = [
110 'comments' => json_collection($comments, 'Comment'),
111 'has_more' => $hasMore,
112 'has_more_id' => $this->params->parentId,
113 'included_comments' => json_collection($includedComments, 'Comment'),
114 'pinned_comments' => json_collection($pinnedComments, 'Comment'),
115 'user_votes' => $this->getUserVotes($allComments),
116 'user_follow' => $this->getUserFollow(),
117 'users' => json_collection($this->getUsers($allComments), 'UserCompact'),
118 'sort' => $this->params->sort,
119 'cursor' => $this->params->cursorHelper->next($comments),
120 ];
121
122 if ($this->params->userId !== null) {
123 $result['user'] = json_item(User::find($this->params->userId), 'UserCompact');
124 }
125
126 if ($this->params->parentId === 0 || $this->params->parentId === null) {
127 $result['top_level_count'] = $this->commentsQuery()->whereNull('parent_id')->count();
128 $result['total'] = $this->commentsQuery()->count();
129 }
130
131 $commentables = $comments->pluck('commentable');
132 // Always include initial commentable in so it can be used for attributes
133 // check even when there's no comment on it.
134 if ($this->commentable !== null) {
135 $commentables[] = $this->commentable;
136 }
137 $commentables = $commentables->uniqueStrict('commentable_identifier')->concat([null]);
138 $result['commentable_meta'] = json_collection($commentables, 'CommentableMeta');
139
140 return $result;
141 }
142
143 public function commentsQuery()
144 {
145 if (isset($this->commentable)) {
146 $query = $this->commentable->comments();
147 } else {
148 $query = Comment::select();
149 }
150
151 if ($this->params->userId !== null) {
152 $query->where('user_id', $this->params->userId);
153 }
154
155 return $query;
156 }
157
158 // This is named explictly for the paginator because there's another count
159 // in ::toArray() which always includes deleted comments.
160 public function countForPaginator()
161 {
162 $query = $this->commentsQuery();
163
164 if (!$this->includeDeleted) {
165 $query->withoutTrashed();
166 }
167 $query->select('id')->limit($GLOBALS['cfg']['osu']['pagination']['max_count'])->unorder();
168
169 return Comment::from($query)->count();
170 }
171
172 private function getComments($query, $isChildren = true, $pinnedOnly = false)
173 {
174 $cursorHelper = $pinnedOnly
175 ? Comment::makeDbCursorHelper('new')
176 : $this->params->cursorHelper;
177 $queryLimit = $this->params->limit;
178
179 if (!$isChildren) {
180 if ($this->params->filterByParentId()) {
181 $query->where(['parent_id' => $this->params->parentIdForWhere()]);
182 }
183
184 $queryLimit++;
185
186 if ($this->params->after === null) {
187 $cursor = $this->params->cursor;
188 } else {
189 $lastComment = Comment::findOrFail($this->params->after);
190 $cursor = $cursorHelper->next([$lastComment]);
191 }
192
193 if ($cursor === null) {
194 $query->offset(max_offset($this->params->page, $this->params->limit));
195 }
196 }
197
198 $query->cursorSort($cursorHelper, $cursor ?? null);
199
200 if (!$this->includeDeleted) {
201 $query->whereNull('deleted_at');
202 }
203
204 if (!$pinnedOnly) {
205 $query->limit($queryLimit);
206 }
207
208 return $query->get();
209 }
210
211 private function getUserFollow()
212 {
213 return $this->commentable !== null &&
214 $this->user !== null &&
215 $this
216 ->user
217 ->follows()
218 ->whereNotifiable($this->commentable)
219 ->where(['subtype' => 'comment'])
220 ->exists();
221 }
222
223 private function getUserVotes($comments)
224 {
225 if ($this->user === null) {
226 return [];
227 }
228
229 $ids = $comments->pluck('id');
230
231 return CommentVote::where(['user_id' => $this->user->getKey()])
232 ->whereIn('comment_id', $ids)
233 ->pluck('comment_id');
234 }
235
236 private function getUsers($comments)
237 {
238 $userIds = $comments->pluck('user_id')
239 ->concat($comments->pluck('edited_by_id'));
240
241 if (priv_check('CommentModerate')->can()) {
242 $userIds = $userIds->concat($comments->pluck('deleted_by_id'));
243 }
244
245 return User::whereIn('user_id', array_reject_null($userIds))->get();
246 }
247}