1// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the GNU Affero General Public License v3.0.
2// See the LICENCE file in the repository root for full licence text.
3
4import CommentJson, { CommentableMetaJson, CommentBundleJson } from 'interfaces/comment-json';
5import UserJson from 'interfaces/user-json';
6import { route } from 'laroute';
7import { isEqual, last } from 'lodash';
8import { action, makeObservable, observable, runInAction } from 'mobx';
9import Comment from 'models/comment';
10import core from 'osu-core-singleton';
11import { onError } from 'utils/ajax';
12import { trans } from 'utils/lang';
13import { switchNever } from 'utils/switch-never';
14
15export interface BaseCommentableMeta {
16 id: number;
17 type: string;
18}
19
20export type CommentEditMode = 'edit' | 'new' | 'reply';
21
22export interface PostParams {
23 commentableMeta?: CommentableMetaJson;
24 id?: number;
25 message: string;
26 mode: CommentEditMode;
27 parentId?: number;
28}
29
30interface State {
31 commentableMetaItems: Partial<Record<string, CommentableMetaJson>>;
32 commentIdsByParentId: Partial<Record<number, number[]>>;
33 comments: Partial<Record<number, Comment>>;
34 hasMore: Partial<Record<number, boolean>>;
35 isFollowing: boolean; // only for the first commentableMetaItem
36 pinnedCommentIds: number[];
37 sort: string;
38 topLevelCount: number;
39 total: number;
40 users: Partial<Record<number, UserJson>>;
41 votedCommentIds: Set<number>;
42}
43
44interface StateJson {
45 commentableMetaItems: Partial<Record<string, CommentableMetaJson>>;
46 commentIdsByParentId: Partial<Record<number, number[]>>;
47 comments: CommentJson[];
48 hasMore: Partial<Record<number, boolean>>;
49 isFollowing: boolean;
50 pinnedCommentIds: number[];
51 sort: string;
52 topLevelCount: number;
53 total: number;
54 users: Partial<Record<number, UserJson>>;
55 votedCommentIds: number[];
56}
57
58interface XhrCollection {
59 [Delete: `delete-${number}`]: JQuery.jqXHR<CommentBundleJson>;
60 [Load: `load-${number}`]: JQuery.jqXHR<CommentBundleJson>;
61 [Pin: `pin-${number}`]: JQuery.jqXHR<CommentBundleJson>;
62 [Post: `${CommentEditMode}-${number | string}`]: JQuery.jqXHR<CommentBundleJson>;
63 [Vote: `vote-${number}`]: JQuery.jqXHR<CommentBundleJson>;
64 follow: JQuery.jqXHR<void>;
65 sort: JQuery.jqXHR<CommentBundleJson>;
66}
67
68interface XhrPostParams {
69 comment: {
70 commentable_id?: number;
71 commentable_type?: string;
72 message: string;
73 parent_id?: number;
74 };
75}
76
77function abortXhrCollection(xhrCollection: Partial<Record<string, JQuery.jqXHR<unknown>>>) {
78 Object.values(xhrCollection).forEach((xhr) => xhr?.abort());
79}
80
81function initialState() {
82 return {
83 commentableMetaItems: {},
84 commentIdsByParentId: {},
85 comments: {},
86 hasMore: {},
87 isFollowing: false,
88 pinnedCommentIds: [],
89 sort: 'new',
90 topLevelCount: 0,
91 total: 0,
92 users: {},
93 votedCommentIds: new Set<number>(),
94 };
95}
96
97function makeMetaId(meta: BaseCommentableMeta | CommentableMetaJson | undefined) {
98 return meta != null && 'id' in meta
99 ? `${meta.type}-${meta.id}`
100 : 'null';
101}
102
103function postXhrKeyId(postParams: PostParams) {
104 switch (postParams.mode) {
105 case 'edit':
106 return postParams.id ?? 0;
107 case 'new':
108 return makeMetaId(postParams.commentableMeta);
109 case 'reply':
110 return postParams.parentId ?? 0;
111 default:
112 switchNever(postParams.mode);
113 throw new Error('unsupported mode');
114 }
115}
116
117export default class CommentsController {
118 @observable nextState: Partial<State> = {};
119 @observable state: State;
120
121 private destroyed = false;
122 @observable private xhr: Partial<XhrCollection> = {};
123
124 get commentableMeta() {
125 return this.state.commentableMetaItems[makeMetaId(this.baseCommentableMeta)];
126 }
127
128 get pinnedComments() {
129 return this.getComments(this.state.pinnedCommentIds);
130 }
131
132 get stateEl() {
133 const ret = (window.newBody ?? document.body).querySelector(this.stateSelector);
134
135 if (ret instanceof HTMLScriptElement) {
136 return ret;
137 }
138
139 throw new Error('missing state element');
140 }
141
142 get topLevelComments() {
143 return this.getComments(this.state.commentIdsByParentId[0] ?? []);
144 }
145
146 constructor(private readonly stateSelector: string, private readonly baseCommentableMeta?: BaseCommentableMeta) {
147 const stateEl = this.stateEl;
148 const savedStateJson = stateEl.dataset.savedState;
149 if (savedStateJson != null) {
150 this.state = this.stateFromJson(JSON.parse(savedStateJson) as StateJson);
151 } else {
152 this.state = initialState();
153 const initialBundle = JSON.parse(stateEl.text) as CommentBundleJson;
154 this.loadBundle(initialBundle, true, true);
155 }
156
157 makeObservable(this);
158
159 document.addEventListener('turbo:before-cache', this.destroy);
160 }
161
162 @action
163 apiDelete(comment: Comment) {
164 if (this.isDeleting(comment) || !confirm(trans('common.confirmation'))) return;
165
166 const xhrKey = `delete-${comment.id}` as const;
167 this.xhr[xhrKey] = $.ajax(route('comments.destroy', { comment: comment.id }), { method: 'DELETE' });
168
169 this.xhr[xhrKey]
170 ?.done((bundle) => this.loadBundle(bundle))
171 .fail(onError)
172 .always(action(() => {
173 delete(this.xhr[xhrKey]);
174 }));
175 }
176
177 @action
178 apiLoadMore(parent: Comment | null | undefined) {
179 if (this.isLoading(parent)) return;
180
181 const parentId = parent?.id ?? 0;
182
183 const params: Partial<Record<string, unknown>> = {
184 parent_id: parentId,
185 sort: this.state.sort,
186 };
187
188 if (parent == null) {
189 if (this.baseCommentableMeta != null) {
190 params.commentable_id = this.baseCommentableMeta.id;
191 params.commentable_type = this.baseCommentableMeta.type;
192 }
193 } else {
194 params.commentable_id = parent.commentableId;
195 params.commentable_type = parent.commentableType;
196 }
197
198 const lastCommentId = last(this.state.commentIdsByParentId[parentId] ?? []);
199 if (lastCommentId != null) {
200 params.after = lastCommentId;
201 }
202
203 const xhrKey = `load-${parentId}` as const;
204 this.xhr[xhrKey] = $.ajax(route('comments.index'), { data: params, dataType: 'json' });
205 this.xhr[xhrKey]
206 ?.done((bundle) => this.loadBundle(bundle))
207 .always(action(() => {
208 delete(this.xhr[xhrKey]);
209 }));
210 }
211
212 @action
213 apiPost(postParams: PostParams, doneCallback: () => void) {
214 if (this.isPosting(postParams)) return;
215
216 if (core.userLogin.showIfGuest(() => this.apiPost(postParams, doneCallback))) return;
217
218 const params: XhrPostParams = {
219 comment: { message: postParams.message },
220 };
221
222 let url = route('comments.store');
223 let method = 'POST';
224
225 switch (postParams.mode) {
226 case 'edit':
227 if (postParams.id == null) {
228 throw new Error('missing post id in edit mode');
229 }
230 url = route('comments.update', { comment: postParams.id });
231 method = 'PUT';
232 break;
233
234 case 'new':
235 if (postParams.commentableMeta == null || !('id' in postParams.commentableMeta)) {
236 throw new Error('missing commentable meta in new mode');
237 }
238 params.comment.commentable_type = postParams.commentableMeta.type;
239 params.comment.commentable_id = postParams.commentableMeta.id;
240 break;
241
242 case 'reply':
243 if (postParams.parentId == null) {
244 throw new Error('missing parent in reply mode');
245 }
246 params.comment.parent_id = postParams.parentId;
247 break;
248
249 default:
250 switchNever(postParams.mode);
251 throw new Error('unsupported mode');
252 }
253
254 const xhrKey = `${postParams.mode}-${postXhrKeyId(postParams)}` as const;
255
256 this.xhr[xhrKey] = $.ajax(url, { data: params, method });
257 this.xhr[xhrKey]
258 ?.always(action(() => {
259 delete(this.xhr[xhrKey]);
260 })).done((bundle) => runInAction(() => {
261 doneCallback();
262 this.loadBundle(bundle, false);
263 })).fail(onError);
264 }
265
266 @action
267 apiRestore(comment: Comment) {
268 if (this.isDeleting(comment) || !confirm(trans('common.confirmation'))) {
269 return;
270 }
271
272 const xhrKey = `delete-${comment.id}` as const;
273 this.xhr[xhrKey] = $.ajax(route('comments.restore', { comment: comment.id }), { method: 'POST' });
274
275 this.xhr[xhrKey]
276 ?.done((bundle) => this.loadBundle(bundle))
277 .fail(onError)
278 .always(action(() => {
279 delete(this.xhr[xhrKey]);
280 }));
281 }
282
283 @action
284 apiSetSort(sort: string) {
285 if (this.xhr.sort != null) return;
286
287 this.nextState.sort = sort;
288
289 const params: Record<string, unknown> = {
290 parent_id: 0,
291 sort,
292 };
293 if (this.commentableMeta != null && 'id' in this.commentableMeta) {
294 params.commentable_id = this.commentableMeta.id;
295 params.commentable_type = this.commentableMeta.type;
296 }
297
298 this.xhr.sort = $.ajax(route('comments.index'), {
299 data: params,
300 dataType: 'json',
301 });
302 this.xhr.sort
303 .done((bundle) => runInAction(() => {
304 abortXhrCollection(this.xhr);
305 this.state = initialState();
306 this.nextState = {};
307 this.xhr = {};
308 this.loadBundle(bundle, true, true);
309 core.userPreferences.set('comments_sort', this.state.sort);
310 }));
311 }
312
313 @action
314 apiToggleFollow() {
315 if (this.nextState.isFollowing != null) return;
316
317 const meta = this.commentableMeta;
318
319 if (meta == null || !('id' in meta)) return;
320
321 const isFollowing = this.nextState.isFollowing = !this.state.isFollowing;
322
323 this.xhr.follow = $.ajax(route('follows.store'), {
324 data: {
325 follow: {
326 notifiable_id: meta.id,
327 notifiable_type: meta.type,
328 subtype: 'comment',
329 },
330 },
331 dataType: 'json',
332 method: this.nextState.isFollowing ? 'POST' : 'DELETE',
333 });
334 this.xhr.follow
335 .always(action(() => {
336 delete(this.xhr.follow);
337 this.nextState.isFollowing = undefined;
338 })).done(action(() => {
339 this.state.isFollowing = isFollowing;
340 })).fail(onError);
341 }
342
343 @action
344 apiTogglePin(comment: Comment) {
345 const xhrKey = `pin-${comment.id}` as const;
346 if (this.xhr[xhrKey] != null || !comment.canPin) {
347 return;
348 }
349
350 this.xhr[xhrKey] = $.ajax(route('comments.pin', { comment: comment.id }), {
351 method: comment.pinned ? 'DELETE' : 'POST',
352 });
353 this.xhr[xhrKey]
354 ?.done((bundle) => this.loadBundle(bundle))
355 .fail(onError)
356 .always(action(() => {
357 delete(this.xhr[xhrKey]);
358 }));
359 }
360
361 @action
362 apiToggleVote(comment: Comment) {
363 if (this.isVoting(comment)) return;
364
365 if (core.userLogin.showIfGuest(() => this.apiToggleVote(comment))) return;
366
367 let method: string;
368 let storeMethod: 'add' | 'delete';
369
370 if (this.state.votedCommentIds.has(comment.id)) {
371 method = 'DELETE';
372 storeMethod = 'delete';
373 } else {
374 method = 'POST';
375 storeMethod = 'add';
376 }
377
378 const xhrKey = `vote-${comment.id}` as const;
379 this.xhr[xhrKey] = $.ajax(route('comments.vote', { comment: comment.id }), { method });
380 this.xhr[xhrKey]
381 ?.done((bundle) => runInAction(() => {
382 this.loadBundle(bundle);
383 this.state.votedCommentIds[storeMethod](comment.id);
384 })).fail(onError)
385 .always(action(() => {
386 delete(this.xhr[xhrKey]);
387 }));
388 }
389
390 readonly destroy = () => {
391 if (this.destroyed) return;
392
393 document.removeEventListener('turbo:before-cache', this.destroy);
394 abortXhrCollection(this.xhr);
395 this.stateStore();
396 this.destroyed = true;
397 };
398
399 getCommentableMeta(comment: Comment) {
400 return this.state.commentableMetaItems[`${comment.commentableType}-${comment.commentableId}`] ?? { title: '' };
401 }
402
403 getComments(ids: number[] | undefined) {
404 const ret = [];
405
406 for (const id of ids ?? []) {
407 const comment = this.state.comments[id];
408
409 if (comment != null) {
410 ret.push(comment);
411 }
412 }
413
414 return ret;
415 }
416
417 getReplies(comment: Comment) {
418 const ids = this.state.commentIdsByParentId[comment.id];
419
420 return this.getComments(ids);
421 }
422
423 getUser(id: number | null | undefined) {
424 return id == null ? undefined : this.state.users[id];
425 }
426
427 isLoading(parent: Comment | null | undefined) {
428 return this.xhr[`load-${parent?.id ?? 0}`] != null;
429 }
430
431 isPosting(postParams: PostParams) {
432 return this.xhr[`${postParams.mode}-${postXhrKeyId(postParams)}`] != null;
433 }
434
435 isVoting(comment: Comment) {
436 return this.xhr[`vote-${comment.id}`] != null;
437 }
438
439 private addComment(commentJson: CommentJson) {
440 const id = commentJson.id;
441 if (this.state.comments[id]?.updatedAt !== commentJson.updated_at) {
442 this.state.comments[id] = new Comment(commentJson, this);
443 }
444 }
445
446 private addCommentId(comment: CommentJson, append: boolean) {
447 const parentId = comment.parent_id ?? 0;
448 this.state.commentIdsByParentId[parentId] ??= [];
449
450 // The `?? []` shouldn't ever happen.
451 const ids = this.state.commentIdsByParentId[parentId] ?? [];
452 const newId = comment.id;
453 if (!ids.includes(newId)) {
454 if (append) {
455 ids.push(newId);
456 } else {
457 ids.unshift(newId);
458 }
459 }
460 }
461
462 private isDeleting(comment: Comment) {
463 return this.xhr[`delete-${comment.id}`] != null;
464 }
465
466 @action
467 private loadBundle(bundle: CommentBundleJson, append = true, initial = false) {
468 if (initial) {
469 // for initial page of comment index and show
470 this.state.commentIdsByParentId[-1] = bundle.comments.map((comment) => comment.id);
471 this.state.sort = bundle.sort;
472 append = true;
473 }
474
475 bundle.comments.forEach((comment) => {
476 this.addCommentId(comment, append);
477 this.addComment(comment);
478 });
479
480 bundle.included_comments.forEach((comment) => {
481 this.addCommentId(comment, true);
482 this.addComment(comment);
483 });
484 this.state.pinnedCommentIds = [];
485 (bundle.pinned_comments ?? []).forEach((comment) => {
486 this.state.pinnedCommentIds.push(comment.id);
487 this.addComment(comment);
488 });
489
490 bundle.user_votes.forEach((v) => this.state.votedCommentIds.add(v));
491
492 this.state.isFollowing = bundle.user_follow;
493 this.state.hasMore[bundle.has_more_id] = bundle.has_more;
494 if (bundle.top_level_count != null && bundle.total != null) {
495 this.state.topLevelCount = bundle.top_level_count;
496 this.state.total = bundle.total;
497 }
498
499 for (const user of bundle.users) {
500 const id = user.id;
501 if (!isEqual(this.state.users[id], user)) {
502 this.state.users[id] = user;
503 }
504 }
505 for (const meta of bundle.commentable_meta) {
506 const id = makeMetaId(meta);
507 if (!isEqual(this.state.commentableMetaItems[id], meta)) {
508 this.state.commentableMetaItems[id] = meta;
509 }
510 }
511 }
512
513 @action
514 private stateFromJson(json: StateJson): State {
515 const comments: State['comments'] = {};
516 for (const commentJson of json.comments) {
517 comments[commentJson.id] = new Comment(commentJson, this);
518 }
519
520 return {
521 commentableMetaItems: json.commentableMetaItems,
522 commentIdsByParentId: json.commentIdsByParentId,
523 comments,
524 hasMore: json.hasMore,
525 isFollowing: json.isFollowing,
526 pinnedCommentIds: json.pinnedCommentIds,
527 sort: json.sort,
528 topLevelCount: json.topLevelCount,
529 total: json.total,
530 users: json.users,
531 votedCommentIds: new Set(json.votedCommentIds),
532 };
533 }
534
535 @action
536 private stateStore() {
537 const comments: StateJson['comments'] = [];
538 for (const commentModel of Object.values(this.state.comments)) {
539 if (commentModel != null) {
540 comments.push(commentModel.toJson());
541 }
542 }
543
544 const json: StateJson = {
545 commentableMetaItems: this.state.commentableMetaItems,
546 commentIdsByParentId: this.state.commentIdsByParentId,
547 comments,
548 hasMore: this.state.hasMore,
549 isFollowing: this.state.isFollowing,
550 pinnedCommentIds: this.state.pinnedCommentIds,
551 sort: this.state.sort,
552 topLevelCount: this.state.topLevelCount,
553 total: this.state.total,
554 users: this.state.users,
555 votedCommentIds: [...this.state.votedCommentIds],
556 };
557
558 this.stateEl.dataset.savedState = JSON.stringify(json);
559 }
560}