the browser-facing portion of osu!
at master 16 kB view raw
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}