]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Make pages use query params instead of route params where appropriate (#977)
[lemmy-ui.git] / src / shared / components / comment / comment-node.tsx
1 import classNames from "classnames";
2 import { Component, linkEvent } from "inferno";
3 import { Link } from "inferno-router";
4 import {
5   AddAdmin,
6   AddModToCommunity,
7   BanFromCommunity,
8   BanPerson,
9   BlockPerson,
10   CommentNode as CommentNodeI,
11   CommentReplyView,
12   CommentView,
13   CommunityModeratorView,
14   CreateCommentLike,
15   CreateCommentReport,
16   DeleteComment,
17   EditComment,
18   GetComments,
19   Language,
20   ListingType,
21   MarkCommentReplyAsRead,
22   MarkPersonMentionAsRead,
23   PersonMentionView,
24   PersonViewSafe,
25   PurgeComment,
26   PurgePerson,
27   RemoveComment,
28   SaveComment,
29   TransferCommunity,
30 } from "lemmy-js-client";
31 import moment from "moment";
32 import { i18n } from "../../i18next";
33 import { BanType, CommentViewType, PurgeType } from "../../interfaces";
34 import { UserService, WebSocketService } from "../../services";
35 import {
36   amCommunityCreator,
37   canAdmin,
38   canMod,
39   colorList,
40   commentTreeMaxDepth,
41   futureDaysToUnixTime,
42   isAdmin,
43   isBanned,
44   isMod,
45   mdToHtml,
46   mdToHtmlNoImages,
47   myAuth,
48   numToSI,
49   setupTippy,
50   showScores,
51   wsClient,
52 } from "../../utils";
53 import { Icon, PurgeWarning, Spinner } from "../common/icon";
54 import { MomentTime } from "../common/moment-time";
55 import { CommunityLink } from "../community/community-link";
56 import { PersonListing } from "../person/person-listing";
57 import { CommentForm } from "./comment-form";
58 import { CommentNodes } from "./comment-nodes";
59
60 interface CommentNodeState {
61   showReply: boolean;
62   showEdit: boolean;
63   showRemoveDialog: boolean;
64   removeReason?: string;
65   showBanDialog: boolean;
66   removeData: boolean;
67   banReason?: string;
68   banExpireDays?: number;
69   banType: BanType;
70   showPurgeDialog: boolean;
71   purgeReason?: string;
72   purgeType: PurgeType;
73   purgeLoading: boolean;
74   showConfirmTransferSite: boolean;
75   showConfirmTransferCommunity: boolean;
76   showConfirmAppointAsMod: boolean;
77   showConfirmAppointAsAdmin: boolean;
78   collapsed: boolean;
79   viewSource: boolean;
80   showAdvanced: boolean;
81   showReportDialog: boolean;
82   reportReason?: string;
83   my_vote?: number;
84   score: number;
85   upvotes: number;
86   downvotes: number;
87   readLoading: boolean;
88   saveLoading: boolean;
89 }
90
91 interface CommentNodeProps {
92   node: CommentNodeI;
93   moderators?: CommunityModeratorView[];
94   admins?: PersonViewSafe[];
95   noBorder?: boolean;
96   noIndent?: boolean;
97   viewOnly?: boolean;
98   locked?: boolean;
99   markable?: boolean;
100   showContext?: boolean;
101   showCommunity?: boolean;
102   enableDownvotes?: boolean;
103   viewType: CommentViewType;
104   allLanguages: Language[];
105   siteLanguages: number[];
106   hideImages?: boolean;
107 }
108
109 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
110   state: CommentNodeState = {
111     showReply: false,
112     showEdit: false,
113     showRemoveDialog: false,
114     showBanDialog: false,
115     removeData: false,
116     banType: BanType.Community,
117     showPurgeDialog: false,
118     purgeLoading: false,
119     purgeType: PurgeType.Person,
120     collapsed: false,
121     viewSource: false,
122     showAdvanced: false,
123     showConfirmTransferSite: false,
124     showConfirmTransferCommunity: false,
125     showConfirmAppointAsMod: false,
126     showConfirmAppointAsAdmin: false,
127     showReportDialog: false,
128     my_vote: this.props.node.comment_view.my_vote,
129     score: this.props.node.comment_view.counts.score,
130     upvotes: this.props.node.comment_view.counts.upvotes,
131     downvotes: this.props.node.comment_view.counts.downvotes,
132     readLoading: false,
133     saveLoading: false,
134   };
135
136   constructor(props: any, context: any) {
137     super(props, context);
138
139     this.handleReplyCancel = this.handleReplyCancel.bind(this);
140     this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
141     this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
142   }
143
144   // TODO see if there's a better way to do this, and all willReceiveProps
145   componentWillReceiveProps(nextProps: CommentNodeProps) {
146     let cv = nextProps.node.comment_view;
147     this.setState({
148       my_vote: cv.my_vote,
149       upvotes: cv.counts.upvotes,
150       downvotes: cv.counts.downvotes,
151       score: cv.counts.score,
152       readLoading: false,
153       saveLoading: false,
154     });
155   }
156
157   render() {
158     let node = this.props.node;
159     let cv = this.props.node.comment_view;
160
161     let purgeTypeText =
162       this.state.purgeType == PurgeType.Comment
163         ? i18n.t("purge_comment")
164         : `${i18n.t("purge")} ${cv.creator.name}`;
165
166     let canMod_ =
167       canMod(cv.creator.id, this.props.moderators, this.props.admins) &&
168       cv.community.local;
169     let canModOnSelf =
170       canMod(
171         cv.creator.id,
172         this.props.moderators,
173         this.props.admins,
174         UserService.Instance.myUserInfo,
175         true
176       ) && cv.community.local;
177     let canAdmin_ =
178       canAdmin(cv.creator.id, this.props.admins) && cv.community.local;
179     let canAdminOnSelf =
180       canAdmin(
181         cv.creator.id,
182         this.props.admins,
183         UserService.Instance.myUserInfo,
184         true
185       ) && cv.community.local;
186     let isMod_ = isMod(cv.creator.id, this.props.moderators);
187     let isAdmin_ =
188       isAdmin(cv.creator.id, this.props.admins) && cv.community.local;
189     let amCommunityCreator_ = amCommunityCreator(
190       cv.creator.id,
191       this.props.moderators
192     );
193
194     let borderColor = this.props.node.depth
195       ? colorList[(this.props.node.depth - 1) % colorList.length]
196       : colorList[0];
197     let moreRepliesBorderColor = this.props.node.depth
198       ? colorList[this.props.node.depth % colorList.length]
199       : colorList[0];
200
201     let showMoreChildren =
202       this.props.viewType == CommentViewType.Tree &&
203       !this.state.collapsed &&
204       node.children.length == 0 &&
205       node.comment_view.counts.child_count > 0;
206
207     return (
208       <div
209         className={`comment ${
210           this.props.node.depth && !this.props.noIndent ? "ml-1" : ""
211         }`}
212       >
213         <div
214           id={`comment-${cv.comment.id}`}
215           className={classNames(`details comment-node py-2`, {
216             "border-top border-light": !this.props.noBorder,
217             mark:
218               this.isCommentNew ||
219               this.props.node.comment_view.comment.distinguished,
220           })}
221           style={
222             !this.props.noIndent && this.props.node.depth
223               ? `border-left: 2px ${borderColor} solid !important`
224               : ""
225           }
226         >
227           <div
228             className={classNames({
229               "ml-2": !this.props.noIndent && this.props.node.depth,
230             })}
231           >
232             <div className="d-flex flex-wrap align-items-center text-muted small">
233               <span className="mr-2">
234                 <PersonListing person={cv.creator} />
235               </span>
236               {cv.comment.distinguished && (
237                 <Icon icon="shield" inline classes={`text-danger mr-2`} />
238               )}
239               {isMod_ && (
240                 <div className="badge badge-light d-none d-sm-inline mr-2">
241                   {i18n.t("mod")}
242                 </div>
243               )}
244               {isAdmin_ && (
245                 <div className="badge badge-light d-none d-sm-inline mr-2">
246                   {i18n.t("admin")}
247                 </div>
248               )}
249               {this.isPostCreator && (
250                 <div className="badge badge-light d-none d-sm-inline mr-2">
251                   {i18n.t("creator")}
252                 </div>
253               )}
254               {cv.creator.bot_account && (
255                 <div className="badge badge-light d-none d-sm-inline mr-2">
256                   {i18n.t("bot_account").toLowerCase()}
257                 </div>
258               )}
259               {(cv.creator_banned_from_community || isBanned(cv.creator)) && (
260                 <div className="badge badge-danger mr-2">
261                   {i18n.t("banned")}
262                 </div>
263               )}
264               {this.props.showCommunity && (
265                 <>
266                   <span className="mx-1">{i18n.t("to")}</span>
267                   <CommunityLink community={cv.community} />
268                   <span className="mx-2">•</span>
269                   <Link className="mr-2" to={`/post/${cv.post.id}`}>
270                     {cv.post.name}
271                   </Link>
272                 </>
273               )}
274               <button
275                 className="btn btn-sm text-muted"
276                 onClick={linkEvent(this, this.handleCommentCollapse)}
277                 aria-label={this.expandText}
278                 data-tippy-content={this.expandText}
279               >
280                 {this.state.collapsed ? (
281                   <Icon icon="plus-square" classes="icon-inline" />
282                 ) : (
283                   <Icon icon="minus-square" classes="icon-inline" />
284                 )}
285               </button>
286               {this.linkBtn(true)}
287               {/* This is an expanding spacer for mobile */}
288               <div className="mr-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
289               {showScores() && (
290                 <>
291                   <a
292                     className={`unselectable pointer ${this.scoreColor}`}
293                     onClick={this.handleCommentUpvote}
294                     data-tippy-content={this.pointsTippy}
295                   >
296                     <span
297                       className="mr-1 font-weight-bold"
298                       aria-label={i18n.t("number_of_points", {
299                         count: this.state.score,
300                         formattedCount: this.state.score,
301                       })}
302                     >
303                       {numToSI(this.state.score)}
304                     </span>
305                   </a>
306                   <span className="mr-1">•</span>
307                 </>
308               )}
309               <span>
310                 <MomentTime
311                   published={cv.comment.published}
312                   updated={cv.comment.updated}
313                 />
314               </span>
315             </div>
316             {/* end of user row */}
317             {this.state.showEdit && (
318               <CommentForm
319                 node={node}
320                 edit
321                 onReplyCancel={this.handleReplyCancel}
322                 disabled={this.props.locked}
323                 focus
324                 allLanguages={this.props.allLanguages}
325                 siteLanguages={this.props.siteLanguages}
326               />
327             )}
328             {!this.state.showEdit && !this.state.collapsed && (
329               <div>
330                 {this.state.viewSource ? (
331                   <pre>{this.commentUnlessRemoved}</pre>
332                 ) : (
333                   <div
334                     className="md-div"
335                     dangerouslySetInnerHTML={
336                       this.props.hideImages
337                         ? mdToHtmlNoImages(this.commentUnlessRemoved)
338                         : mdToHtml(this.commentUnlessRemoved)
339                     }
340                   />
341                 )}
342                 <div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
343                   {this.props.showContext && this.linkBtn()}
344                   {this.props.markable && (
345                     <button
346                       className="btn btn-link btn-animate text-muted"
347                       onClick={linkEvent(this, this.handleMarkRead)}
348                       data-tippy-content={
349                         this.commentReplyOrMentionRead
350                           ? i18n.t("mark_as_unread")
351                           : i18n.t("mark_as_read")
352                       }
353                       aria-label={
354                         this.commentReplyOrMentionRead
355                           ? i18n.t("mark_as_unread")
356                           : i18n.t("mark_as_read")
357                       }
358                     >
359                       {this.state.readLoading ? (
360                         this.loadingIcon
361                       ) : (
362                         <Icon
363                           icon="check"
364                           classes={`icon-inline ${
365                             this.commentReplyOrMentionRead && "text-success"
366                           }`}
367                         />
368                       )}
369                     </button>
370                   )}
371                   {UserService.Instance.myUserInfo && !this.props.viewOnly && (
372                     <>
373                       <button
374                         className={`btn btn-link btn-animate ${
375                           this.state.my_vote == 1 ? "text-info" : "text-muted"
376                         }`}
377                         onClick={this.handleCommentUpvote}
378                         data-tippy-content={i18n.t("upvote")}
379                         aria-label={i18n.t("upvote")}
380                       >
381                         <Icon icon="arrow-up1" classes="icon-inline" />
382                         {showScores() &&
383                           this.state.upvotes !== this.state.score && (
384                             <span className="ml-1">
385                               {numToSI(this.state.upvotes)}
386                             </span>
387                           )}
388                       </button>
389                       {this.props.enableDownvotes && (
390                         <button
391                           className={`btn btn-link btn-animate ${
392                             this.state.my_vote == -1
393                               ? "text-danger"
394                               : "text-muted"
395                           }`}
396                           onClick={this.handleCommentDownvote}
397                           data-tippy-content={i18n.t("downvote")}
398                           aria-label={i18n.t("downvote")}
399                         >
400                           <Icon icon="arrow-down1" classes="icon-inline" />
401                           {showScores() &&
402                             this.state.upvotes !== this.state.score && (
403                               <span className="ml-1">
404                                 {numToSI(this.state.downvotes)}
405                               </span>
406                             )}
407                         </button>
408                       )}
409                       <button
410                         className="btn btn-link btn-animate text-muted"
411                         onClick={linkEvent(this, this.handleReplyClick)}
412                         data-tippy-content={i18n.t("reply")}
413                         aria-label={i18n.t("reply")}
414                       >
415                         <Icon icon="reply1" classes="icon-inline" />
416                       </button>
417                       {!this.state.showAdvanced ? (
418                         <button
419                           className="btn btn-link btn-animate text-muted"
420                           onClick={linkEvent(this, this.handleShowAdvanced)}
421                           data-tippy-content={i18n.t("more")}
422                           aria-label={i18n.t("more")}
423                         >
424                           <Icon icon="more-vertical" classes="icon-inline" />
425                         </button>
426                       ) : (
427                         <>
428                           {!this.myComment && (
429                             <>
430                               <button className="btn btn-link btn-animate">
431                                 <Link
432                                   className="text-muted"
433                                   to={`/create_private_message/${cv.creator.id}`}
434                                   title={i18n.t("message").toLowerCase()}
435                                 >
436                                   <Icon icon="mail" />
437                                 </Link>
438                               </button>
439                               <button
440                                 className="btn btn-link btn-animate text-muted"
441                                 onClick={linkEvent(
442                                   this,
443                                   this.handleShowReportDialog
444                                 )}
445                                 data-tippy-content={i18n.t(
446                                   "show_report_dialog"
447                                 )}
448                                 aria-label={i18n.t("show_report_dialog")}
449                               >
450                                 <Icon icon="flag" />
451                               </button>
452                               <button
453                                 className="btn btn-link btn-animate text-muted"
454                                 onClick={linkEvent(
455                                   this,
456                                   this.handleBlockUserClick
457                                 )}
458                                 data-tippy-content={i18n.t("block_user")}
459                                 aria-label={i18n.t("block_user")}
460                               >
461                                 <Icon icon="slash" />
462                               </button>
463                             </>
464                           )}
465                           <button
466                             className="btn btn-link btn-animate text-muted"
467                             onClick={linkEvent(
468                               this,
469                               this.handleSaveCommentClick
470                             )}
471                             data-tippy-content={
472                               cv.saved ? i18n.t("unsave") : i18n.t("save")
473                             }
474                             aria-label={
475                               cv.saved ? i18n.t("unsave") : i18n.t("save")
476                             }
477                           >
478                             {this.state.saveLoading ? (
479                               this.loadingIcon
480                             ) : (
481                               <Icon
482                                 icon="star"
483                                 classes={`icon-inline ${
484                                   cv.saved && "text-warning"
485                                 }`}
486                               />
487                             )}
488                           </button>
489                           <button
490                             className="btn btn-link btn-animate text-muted"
491                             onClick={linkEvent(this, this.handleViewSource)}
492                             data-tippy-content={i18n.t("view_source")}
493                             aria-label={i18n.t("view_source")}
494                           >
495                             <Icon
496                               icon="file-text"
497                               classes={`icon-inline ${
498                                 this.state.viewSource && "text-success"
499                               }`}
500                             />
501                           </button>
502                           {this.myComment && (
503                             <>
504                               <button
505                                 className="btn btn-link btn-animate text-muted"
506                                 onClick={linkEvent(this, this.handleEditClick)}
507                                 data-tippy-content={i18n.t("edit")}
508                                 aria-label={i18n.t("edit")}
509                               >
510                                 <Icon icon="edit" classes="icon-inline" />
511                               </button>
512                               <button
513                                 className="btn btn-link btn-animate text-muted"
514                                 onClick={linkEvent(
515                                   this,
516                                   this.handleDeleteClick
517                                 )}
518                                 data-tippy-content={
519                                   !cv.comment.deleted
520                                     ? i18n.t("delete")
521                                     : i18n.t("restore")
522                                 }
523                                 aria-label={
524                                   !cv.comment.deleted
525                                     ? i18n.t("delete")
526                                     : i18n.t("restore")
527                                 }
528                               >
529                                 <Icon
530                                   icon="trash"
531                                   classes={`icon-inline ${
532                                     cv.comment.deleted && "text-danger"
533                                   }`}
534                                 />
535                               </button>
536
537                               {(canModOnSelf || canAdminOnSelf) && (
538                                 <button
539                                   className="btn btn-link btn-animate text-muted"
540                                   onClick={linkEvent(
541                                     this,
542                                     this.handleDistinguishClick
543                                   )}
544                                   data-tippy-content={
545                                     !cv.comment.distinguished
546                                       ? i18n.t("distinguish")
547                                       : i18n.t("undistinguish")
548                                   }
549                                   aria-label={
550                                     !cv.comment.distinguished
551                                       ? i18n.t("distinguish")
552                                       : i18n.t("undistinguish")
553                                   }
554                                 >
555                                   <Icon
556                                     icon="shield"
557                                     classes={`icon-inline ${
558                                       cv.comment.distinguished && "text-danger"
559                                     }`}
560                                   />
561                                 </button>
562                               )}
563                             </>
564                           )}
565                           {/* Admins and mods can remove comments */}
566                           {(canMod_ || canAdmin_) && (
567                             <>
568                               {!cv.comment.removed ? (
569                                 <button
570                                   className="btn btn-link btn-animate text-muted"
571                                   onClick={linkEvent(
572                                     this,
573                                     this.handleModRemoveShow
574                                   )}
575                                   aria-label={i18n.t("remove")}
576                                 >
577                                   {i18n.t("remove")}
578                                 </button>
579                               ) : (
580                                 <button
581                                   className="btn btn-link btn-animate text-muted"
582                                   onClick={linkEvent(
583                                     this,
584                                     this.handleModRemoveSubmit
585                                   )}
586                                   aria-label={i18n.t("restore")}
587                                 >
588                                   {i18n.t("restore")}
589                                 </button>
590                               )}
591                             </>
592                           )}
593                           {/* Mods can ban from community, and appoint as mods to community */}
594                           {canMod_ && (
595                             <>
596                               {!isMod_ &&
597                                 (!cv.creator_banned_from_community ? (
598                                   <button
599                                     className="btn btn-link btn-animate text-muted"
600                                     onClick={linkEvent(
601                                       this,
602                                       this.handleModBanFromCommunityShow
603                                     )}
604                                     aria-label={i18n.t("ban_from_community")}
605                                   >
606                                     {i18n.t("ban_from_community")}
607                                   </button>
608                                 ) : (
609                                   <button
610                                     className="btn btn-link btn-animate text-muted"
611                                     onClick={linkEvent(
612                                       this,
613                                       this.handleModBanFromCommunitySubmit
614                                     )}
615                                     aria-label={i18n.t("unban")}
616                                   >
617                                     {i18n.t("unban")}
618                                   </button>
619                                 ))}
620                               {!cv.creator_banned_from_community &&
621                                 (!this.state.showConfirmAppointAsMod ? (
622                                   <button
623                                     className="btn btn-link btn-animate text-muted"
624                                     onClick={linkEvent(
625                                       this,
626                                       this.handleShowConfirmAppointAsMod
627                                     )}
628                                     aria-label={
629                                       isMod_
630                                         ? i18n.t("remove_as_mod")
631                                         : i18n.t("appoint_as_mod")
632                                     }
633                                   >
634                                     {isMod_
635                                       ? i18n.t("remove_as_mod")
636                                       : i18n.t("appoint_as_mod")}
637                                   </button>
638                                 ) : (
639                                   <>
640                                     <button
641                                       className="btn btn-link btn-animate text-muted"
642                                       aria-label={i18n.t("are_you_sure")}
643                                     >
644                                       {i18n.t("are_you_sure")}
645                                     </button>
646                                     <button
647                                       className="btn btn-link btn-animate text-muted"
648                                       onClick={linkEvent(
649                                         this,
650                                         this.handleAddModToCommunity
651                                       )}
652                                       aria-label={i18n.t("yes")}
653                                     >
654                                       {i18n.t("yes")}
655                                     </button>
656                                     <button
657                                       className="btn btn-link btn-animate text-muted"
658                                       onClick={linkEvent(
659                                         this,
660                                         this.handleCancelConfirmAppointAsMod
661                                       )}
662                                       aria-label={i18n.t("no")}
663                                     >
664                                       {i18n.t("no")}
665                                     </button>
666                                   </>
667                                 ))}
668                             </>
669                           )}
670                           {/* Community creators and admins can transfer community to another mod */}
671                           {(amCommunityCreator_ || canAdmin_) &&
672                             isMod_ &&
673                             cv.creator.local &&
674                             (!this.state.showConfirmTransferCommunity ? (
675                               <button
676                                 className="btn btn-link btn-animate text-muted"
677                                 onClick={linkEvent(
678                                   this,
679                                   this.handleShowConfirmTransferCommunity
680                                 )}
681                                 aria-label={i18n.t("transfer_community")}
682                               >
683                                 {i18n.t("transfer_community")}
684                               </button>
685                             ) : (
686                               <>
687                                 <button
688                                   className="btn btn-link btn-animate text-muted"
689                                   aria-label={i18n.t("are_you_sure")}
690                                 >
691                                   {i18n.t("are_you_sure")}
692                                 </button>
693                                 <button
694                                   className="btn btn-link btn-animate text-muted"
695                                   onClick={linkEvent(
696                                     this,
697                                     this.handleTransferCommunity
698                                   )}
699                                   aria-label={i18n.t("yes")}
700                                 >
701                                   {i18n.t("yes")}
702                                 </button>
703                                 <button
704                                   className="btn btn-link btn-animate text-muted"
705                                   onClick={linkEvent(
706                                     this,
707                                     this
708                                       .handleCancelShowConfirmTransferCommunity
709                                   )}
710                                   aria-label={i18n.t("no")}
711                                 >
712                                   {i18n.t("no")}
713                                 </button>
714                               </>
715                             ))}
716                           {/* Admins can ban from all, and appoint other admins */}
717                           {canAdmin_ && (
718                             <>
719                               {!isAdmin_ && (
720                                 <>
721                                   <button
722                                     className="btn btn-link btn-animate text-muted"
723                                     onClick={linkEvent(
724                                       this,
725                                       this.handlePurgePersonShow
726                                     )}
727                                     aria-label={i18n.t("purge_user")}
728                                   >
729                                     {i18n.t("purge_user")}
730                                   </button>
731                                   <button
732                                     className="btn btn-link btn-animate text-muted"
733                                     onClick={linkEvent(
734                                       this,
735                                       this.handlePurgeCommentShow
736                                     )}
737                                     aria-label={i18n.t("purge_comment")}
738                                   >
739                                     {i18n.t("purge_comment")}
740                                   </button>
741
742                                   {!isBanned(cv.creator) ? (
743                                     <button
744                                       className="btn btn-link btn-animate text-muted"
745                                       onClick={linkEvent(
746                                         this,
747                                         this.handleModBanShow
748                                       )}
749                                       aria-label={i18n.t("ban_from_site")}
750                                     >
751                                       {i18n.t("ban_from_site")}
752                                     </button>
753                                   ) : (
754                                     <button
755                                       className="btn btn-link btn-animate text-muted"
756                                       onClick={linkEvent(
757                                         this,
758                                         this.handleModBanSubmit
759                                       )}
760                                       aria-label={i18n.t("unban_from_site")}
761                                     >
762                                       {i18n.t("unban_from_site")}
763                                     </button>
764                                   )}
765                                 </>
766                               )}
767                               {!isBanned(cv.creator) &&
768                                 cv.creator.local &&
769                                 (!this.state.showConfirmAppointAsAdmin ? (
770                                   <button
771                                     className="btn btn-link btn-animate text-muted"
772                                     onClick={linkEvent(
773                                       this,
774                                       this.handleShowConfirmAppointAsAdmin
775                                     )}
776                                     aria-label={
777                                       isAdmin_
778                                         ? i18n.t("remove_as_admin")
779                                         : i18n.t("appoint_as_admin")
780                                     }
781                                   >
782                                     {isAdmin_
783                                       ? i18n.t("remove_as_admin")
784                                       : i18n.t("appoint_as_admin")}
785                                   </button>
786                                 ) : (
787                                   <>
788                                     <button className="btn btn-link btn-animate text-muted">
789                                       {i18n.t("are_you_sure")}
790                                     </button>
791                                     <button
792                                       className="btn btn-link btn-animate text-muted"
793                                       onClick={linkEvent(
794                                         this,
795                                         this.handleAddAdmin
796                                       )}
797                                       aria-label={i18n.t("yes")}
798                                     >
799                                       {i18n.t("yes")}
800                                     </button>
801                                     <button
802                                       className="btn btn-link btn-animate text-muted"
803                                       onClick={linkEvent(
804                                         this,
805                                         this.handleCancelConfirmAppointAsAdmin
806                                       )}
807                                       aria-label={i18n.t("no")}
808                                     >
809                                       {i18n.t("no")}
810                                     </button>
811                                   </>
812                                 ))}
813                             </>
814                           )}
815                         </>
816                       )}
817                     </>
818                   )}
819                 </div>
820                 {/* end of button group */}
821               </div>
822             )}
823           </div>
824         </div>
825         {showMoreChildren && (
826           <div
827             className={`details ml-1 comment-node py-2 ${
828               !this.props.noBorder ? "border-top border-light" : ""
829             }`}
830             style={`border-left: 2px ${moreRepliesBorderColor} solid !important`}
831           >
832             <button
833               className="btn btn-link text-muted"
834               onClick={linkEvent(this, this.handleFetchChildren)}
835             >
836               {i18n.t("x_more_replies", {
837                 count: node.comment_view.counts.child_count,
838                 formattedCount: numToSI(node.comment_view.counts.child_count),
839               })}{" "}
840               ➔
841             </button>
842           </div>
843         )}
844         {/* end of details */}
845         {this.state.showRemoveDialog && (
846           <form
847             className="form-inline"
848             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
849           >
850             <label
851               className="sr-only"
852               htmlFor={`mod-remove-reason-${cv.comment.id}`}
853             >
854               {i18n.t("reason")}
855             </label>
856             <input
857               type="text"
858               id={`mod-remove-reason-${cv.comment.id}`}
859               className="form-control mr-2"
860               placeholder={i18n.t("reason")}
861               value={this.state.removeReason}
862               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
863             />
864             <button
865               type="submit"
866               className="btn btn-secondary"
867               aria-label={i18n.t("remove_comment")}
868             >
869               {i18n.t("remove_comment")}
870             </button>
871           </form>
872         )}
873         {this.state.showReportDialog && (
874           <form
875             className="form-inline"
876             onSubmit={linkEvent(this, this.handleReportSubmit)}
877           >
878             <label
879               className="sr-only"
880               htmlFor={`report-reason-${cv.comment.id}`}
881             >
882               {i18n.t("reason")}
883             </label>
884             <input
885               type="text"
886               required
887               id={`report-reason-${cv.comment.id}`}
888               className="form-control mr-2"
889               placeholder={i18n.t("reason")}
890               value={this.state.reportReason}
891               onInput={linkEvent(this, this.handleReportReasonChange)}
892             />
893             <button
894               type="submit"
895               className="btn btn-secondary"
896               aria-label={i18n.t("create_report")}
897             >
898               {i18n.t("create_report")}
899             </button>
900           </form>
901         )}
902         {this.state.showBanDialog && (
903           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
904             <div className="form-group row col-12">
905               <label
906                 className="col-form-label"
907                 htmlFor={`mod-ban-reason-${cv.comment.id}`}
908               >
909                 {i18n.t("reason")}
910               </label>
911               <input
912                 type="text"
913                 id={`mod-ban-reason-${cv.comment.id}`}
914                 className="form-control mr-2"
915                 placeholder={i18n.t("reason")}
916                 value={this.state.banReason}
917                 onInput={linkEvent(this, this.handleModBanReasonChange)}
918               />
919               <label
920                 className="col-form-label"
921                 htmlFor={`mod-ban-expires-${cv.comment.id}`}
922               >
923                 {i18n.t("expires")}
924               </label>
925               <input
926                 type="number"
927                 id={`mod-ban-expires-${cv.comment.id}`}
928                 className="form-control mr-2"
929                 placeholder={i18n.t("number_of_days")}
930                 value={this.state.banExpireDays}
931                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
932               />
933               <div className="form-group">
934                 <div className="form-check">
935                   <input
936                     className="form-check-input"
937                     id="mod-ban-remove-data"
938                     type="checkbox"
939                     checked={this.state.removeData}
940                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
941                   />
942                   <label
943                     className="form-check-label"
944                     htmlFor="mod-ban-remove-data"
945                     title={i18n.t("remove_content_more")}
946                   >
947                     {i18n.t("remove_content")}
948                   </label>
949                 </div>
950               </div>
951             </div>
952             {/* TODO hold off on expires until later */}
953             {/* <div class="form-group row"> */}
954             {/*   <label class="col-form-label">Expires</label> */}
955             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
956             {/* </div> */}
957             <div className="form-group row">
958               <button
959                 type="submit"
960                 className="btn btn-secondary"
961                 aria-label={i18n.t("ban")}
962               >
963                 {i18n.t("ban")} {cv.creator.name}
964               </button>
965             </div>
966           </form>
967         )}
968
969         {this.state.showPurgeDialog && (
970           <form onSubmit={linkEvent(this, this.handlePurgeSubmit)}>
971             <PurgeWarning />
972             <label className="sr-only" htmlFor="purge-reason">
973               {i18n.t("reason")}
974             </label>
975             <input
976               type="text"
977               id="purge-reason"
978               className="form-control my-3"
979               placeholder={i18n.t("reason")}
980               value={this.state.purgeReason}
981               onInput={linkEvent(this, this.handlePurgeReasonChange)}
982             />
983             <div className="form-group row col-12">
984               {this.state.purgeLoading ? (
985                 <Spinner />
986               ) : (
987                 <button
988                   type="submit"
989                   className="btn btn-secondary"
990                   aria-label={purgeTypeText}
991                 >
992                   {purgeTypeText}
993                 </button>
994               )}
995             </div>
996           </form>
997         )}
998         {this.state.showReply && (
999           <CommentForm
1000             node={node}
1001             onReplyCancel={this.handleReplyCancel}
1002             disabled={this.props.locked}
1003             focus
1004             allLanguages={this.props.allLanguages}
1005             siteLanguages={this.props.siteLanguages}
1006           />
1007         )}
1008         {!this.state.collapsed && node.children.length > 0 && (
1009           <CommentNodes
1010             nodes={node.children}
1011             locked={this.props.locked}
1012             moderators={this.props.moderators}
1013             admins={this.props.admins}
1014             enableDownvotes={this.props.enableDownvotes}
1015             viewType={this.props.viewType}
1016             allLanguages={this.props.allLanguages}
1017             siteLanguages={this.props.siteLanguages}
1018             hideImages={this.props.hideImages}
1019           />
1020         )}
1021         {/* A collapsed clearfix */}
1022         {this.state.collapsed && <div className="row col-12"></div>}
1023       </div>
1024     );
1025   }
1026
1027   get commentReplyOrMentionRead(): boolean {
1028     let cv = this.props.node.comment_view;
1029
1030     if (this.isPersonMentionType(cv)) {
1031       return cv.person_mention.read;
1032     } else if (this.isCommentReplyType(cv)) {
1033       return cv.comment_reply.read;
1034     } else {
1035       return false;
1036     }
1037   }
1038
1039   linkBtn(small = false) {
1040     let cv = this.props.node.comment_view;
1041     let classnames = classNames("btn btn-link btn-animate text-muted", {
1042       "btn-sm": small,
1043     });
1044
1045     let title = this.props.showContext
1046       ? i18n.t("show_context")
1047       : i18n.t("link");
1048
1049     return (
1050       <>
1051         <Link
1052           className={classnames}
1053           to={`/comment/${cv.comment.id}`}
1054           title={title}
1055         >
1056           <Icon icon="link" classes="icon-inline" />
1057         </Link>
1058         {
1059           <a className={classnames} title={title} href={cv.comment.ap_id}>
1060             <Icon icon="fedilink" classes="icon-inline" />
1061           </a>
1062         }
1063       </>
1064     );
1065   }
1066
1067   get loadingIcon() {
1068     return <Spinner />;
1069   }
1070
1071   get myComment(): boolean {
1072     return (
1073       UserService.Instance.myUserInfo?.local_user_view.person.id ==
1074       this.props.node.comment_view.creator.id
1075     );
1076   }
1077
1078   get isPostCreator(): boolean {
1079     return (
1080       this.props.node.comment_view.creator.id ==
1081       this.props.node.comment_view.post.creator_id
1082     );
1083   }
1084
1085   get commentUnlessRemoved(): string {
1086     let comment = this.props.node.comment_view.comment;
1087     return comment.removed
1088       ? `*${i18n.t("removed")}*`
1089       : comment.deleted
1090       ? `*${i18n.t("deleted")}*`
1091       : comment.content;
1092   }
1093
1094   handleReplyClick(i: CommentNode) {
1095     i.setState({ showReply: true });
1096   }
1097
1098   handleEditClick(i: CommentNode) {
1099     i.setState({ showEdit: true });
1100   }
1101
1102   handleBlockUserClick(i: CommentNode) {
1103     let auth = myAuth();
1104     if (auth) {
1105       let blockUserForm: BlockPerson = {
1106         person_id: i.props.node.comment_view.creator.id,
1107         block: true,
1108         auth,
1109       };
1110       WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
1111     }
1112   }
1113
1114   handleDeleteClick(i: CommentNode) {
1115     let comment = i.props.node.comment_view.comment;
1116     let auth = myAuth();
1117     if (auth) {
1118       let deleteForm: DeleteComment = {
1119         comment_id: comment.id,
1120         deleted: !comment.deleted,
1121         auth,
1122       };
1123       WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
1124     }
1125   }
1126
1127   handleSaveCommentClick(i: CommentNode) {
1128     let cv = i.props.node.comment_view;
1129     let save = cv.saved == undefined ? true : !cv.saved;
1130     let auth = myAuth();
1131     if (auth) {
1132       let form: SaveComment = {
1133         comment_id: cv.comment.id,
1134         save,
1135         auth,
1136       };
1137
1138       WebSocketService.Instance.send(wsClient.saveComment(form));
1139
1140       i.setState({ saveLoading: true });
1141     }
1142   }
1143
1144   handleReplyCancel() {
1145     this.setState({ showReply: false, showEdit: false });
1146   }
1147
1148   handleCommentUpvote(event: any) {
1149     event.preventDefault();
1150     let myVote = this.state.my_vote;
1151     let newVote = myVote == 1 ? 0 : 1;
1152
1153     if (myVote == 1) {
1154       this.setState({
1155         score: this.state.score - 1,
1156         upvotes: this.state.upvotes - 1,
1157       });
1158     } else if (myVote == -1) {
1159       this.setState({
1160         downvotes: this.state.downvotes - 1,
1161         upvotes: this.state.upvotes + 1,
1162         score: this.state.score + 2,
1163       });
1164     } else {
1165       this.setState({
1166         score: this.state.score + 1,
1167         upvotes: this.state.upvotes + 1,
1168       });
1169     }
1170
1171     this.setState({ my_vote: newVote });
1172
1173     let auth = myAuth();
1174     if (auth) {
1175       let form: CreateCommentLike = {
1176         comment_id: this.props.node.comment_view.comment.id,
1177         score: newVote,
1178         auth,
1179       };
1180       WebSocketService.Instance.send(wsClient.likeComment(form));
1181       setupTippy();
1182     }
1183   }
1184
1185   handleCommentDownvote(event: any) {
1186     event.preventDefault();
1187     let myVote = this.state.my_vote;
1188     let newVote = myVote == -1 ? 0 : -1;
1189
1190     if (myVote == 1) {
1191       this.setState({
1192         downvotes: this.state.downvotes + 1,
1193         upvotes: this.state.upvotes - 1,
1194         score: this.state.score - 2,
1195       });
1196     } else if (myVote == -1) {
1197       this.setState({
1198         downvotes: this.state.downvotes - 1,
1199         score: this.state.score + 1,
1200       });
1201     } else {
1202       this.setState({
1203         downvotes: this.state.downvotes + 1,
1204         score: this.state.score - 1,
1205       });
1206     }
1207
1208     this.setState({ my_vote: newVote });
1209
1210     let auth = myAuth();
1211     if (auth) {
1212       let form: CreateCommentLike = {
1213         comment_id: this.props.node.comment_view.comment.id,
1214         score: newVote,
1215         auth,
1216       };
1217
1218       WebSocketService.Instance.send(wsClient.likeComment(form));
1219       setupTippy();
1220     }
1221   }
1222
1223   handleShowReportDialog(i: CommentNode) {
1224     i.setState({ showReportDialog: !i.state.showReportDialog });
1225   }
1226
1227   handleReportReasonChange(i: CommentNode, event: any) {
1228     i.setState({ reportReason: event.target.value });
1229   }
1230
1231   handleReportSubmit(i: CommentNode) {
1232     let comment = i.props.node.comment_view.comment;
1233     let reason = i.state.reportReason;
1234     let auth = myAuth();
1235     if (reason && auth) {
1236       let form: CreateCommentReport = {
1237         comment_id: comment.id,
1238         reason,
1239         auth,
1240       };
1241       WebSocketService.Instance.send(wsClient.createCommentReport(form));
1242       i.setState({ showReportDialog: false });
1243     }
1244   }
1245
1246   handleModRemoveShow(i: CommentNode) {
1247     i.setState({
1248       showRemoveDialog: !i.state.showRemoveDialog,
1249       showBanDialog: false,
1250     });
1251   }
1252
1253   handleModRemoveReasonChange(i: CommentNode, event: any) {
1254     i.setState({ removeReason: event.target.value });
1255   }
1256
1257   handleModRemoveDataChange(i: CommentNode, event: any) {
1258     i.setState({ removeData: event.target.checked });
1259   }
1260
1261   handleModRemoveSubmit(i: CommentNode) {
1262     let comment = i.props.node.comment_view.comment;
1263     let auth = myAuth();
1264     if (auth) {
1265       let form: RemoveComment = {
1266         comment_id: comment.id,
1267         removed: !comment.removed,
1268         reason: i.state.removeReason,
1269         auth,
1270       };
1271       WebSocketService.Instance.send(wsClient.removeComment(form));
1272
1273       i.setState({ showRemoveDialog: false });
1274     }
1275   }
1276
1277   handleDistinguishClick(i: CommentNode) {
1278     let comment = i.props.node.comment_view.comment;
1279     let auth = myAuth();
1280     if (auth) {
1281       let form: EditComment = {
1282         comment_id: comment.id,
1283         distinguished: !comment.distinguished,
1284         auth,
1285       };
1286       WebSocketService.Instance.send(wsClient.editComment(form));
1287       i.setState(i.state);
1288     }
1289   }
1290
1291   isPersonMentionType(
1292     item: CommentView | PersonMentionView | CommentReplyView
1293   ): item is PersonMentionView {
1294     return (item as PersonMentionView).person_mention?.id !== undefined;
1295   }
1296
1297   isCommentReplyType(
1298     item: CommentView | PersonMentionView | CommentReplyView
1299   ): item is CommentReplyView {
1300     return (item as CommentReplyView).comment_reply?.id !== undefined;
1301   }
1302
1303   handleMarkRead(i: CommentNode) {
1304     let auth = myAuth();
1305     if (auth) {
1306       if (i.isPersonMentionType(i.props.node.comment_view)) {
1307         let form: MarkPersonMentionAsRead = {
1308           person_mention_id: i.props.node.comment_view.person_mention.id,
1309           read: !i.props.node.comment_view.person_mention.read,
1310           auth,
1311         };
1312         WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
1313       } else if (i.isCommentReplyType(i.props.node.comment_view)) {
1314         let form: MarkCommentReplyAsRead = {
1315           comment_reply_id: i.props.node.comment_view.comment_reply.id,
1316           read: !i.props.node.comment_view.comment_reply.read,
1317           auth,
1318         };
1319         WebSocketService.Instance.send(wsClient.markCommentReplyAsRead(form));
1320       }
1321
1322       i.setState({ readLoading: true });
1323     }
1324   }
1325
1326   handleModBanFromCommunityShow(i: CommentNode) {
1327     i.setState({
1328       showBanDialog: true,
1329       banType: BanType.Community,
1330       showRemoveDialog: false,
1331     });
1332   }
1333
1334   handleModBanShow(i: CommentNode) {
1335     i.setState({
1336       showBanDialog: true,
1337       banType: BanType.Site,
1338       showRemoveDialog: false,
1339     });
1340   }
1341
1342   handleModBanReasonChange(i: CommentNode, event: any) {
1343     i.setState({ banReason: event.target.value });
1344   }
1345
1346   handleModBanExpireDaysChange(i: CommentNode, event: any) {
1347     i.setState({ banExpireDays: event.target.value });
1348   }
1349
1350   handleModBanFromCommunitySubmit(i: CommentNode) {
1351     i.setState({ banType: BanType.Community });
1352     i.handleModBanBothSubmit(i);
1353   }
1354
1355   handleModBanSubmit(i: CommentNode) {
1356     i.setState({ banType: BanType.Site });
1357     i.handleModBanBothSubmit(i);
1358   }
1359
1360   handleModBanBothSubmit(i: CommentNode) {
1361     let cv = i.props.node.comment_view;
1362     let auth = myAuth();
1363     if (auth) {
1364       if (i.state.banType == BanType.Community) {
1365         // If its an unban, restore all their data
1366         let ban = !cv.creator_banned_from_community;
1367         if (ban == false) {
1368           i.setState({ removeData: false });
1369         }
1370         let form: BanFromCommunity = {
1371           person_id: cv.creator.id,
1372           community_id: cv.community.id,
1373           ban,
1374           remove_data: i.state.removeData,
1375           reason: i.state.banReason,
1376           expires: futureDaysToUnixTime(i.state.banExpireDays),
1377           auth,
1378         };
1379         WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1380       } else {
1381         // If its an unban, restore all their data
1382         let ban = !cv.creator.banned;
1383         if (ban == false) {
1384           i.setState({ removeData: false });
1385         }
1386         let form: BanPerson = {
1387           person_id: cv.creator.id,
1388           ban,
1389           remove_data: i.state.removeData,
1390           reason: i.state.banReason,
1391           expires: futureDaysToUnixTime(i.state.banExpireDays),
1392           auth,
1393         };
1394         WebSocketService.Instance.send(wsClient.banPerson(form));
1395       }
1396
1397       i.setState({ showBanDialog: false });
1398     }
1399   }
1400
1401   handlePurgePersonShow(i: CommentNode) {
1402     i.setState({
1403       showPurgeDialog: true,
1404       purgeType: PurgeType.Person,
1405       showRemoveDialog: false,
1406     });
1407   }
1408
1409   handlePurgeCommentShow(i: CommentNode) {
1410     i.setState({
1411       showPurgeDialog: true,
1412       purgeType: PurgeType.Comment,
1413       showRemoveDialog: false,
1414     });
1415   }
1416
1417   handlePurgeReasonChange(i: CommentNode, event: any) {
1418     i.setState({ purgeReason: event.target.value });
1419   }
1420
1421   handlePurgeSubmit(i: CommentNode, event: any) {
1422     event.preventDefault();
1423     let auth = myAuth();
1424     if (auth) {
1425       if (i.state.purgeType == PurgeType.Person) {
1426         let form: PurgePerson = {
1427           person_id: i.props.node.comment_view.creator.id,
1428           reason: i.state.purgeReason,
1429           auth,
1430         };
1431         WebSocketService.Instance.send(wsClient.purgePerson(form));
1432       } else if (i.state.purgeType == PurgeType.Comment) {
1433         let form: PurgeComment = {
1434           comment_id: i.props.node.comment_view.comment.id,
1435           reason: i.state.purgeReason,
1436           auth,
1437         };
1438         WebSocketService.Instance.send(wsClient.purgeComment(form));
1439       }
1440
1441       i.setState({ purgeLoading: true });
1442     }
1443   }
1444
1445   handleShowConfirmAppointAsMod(i: CommentNode) {
1446     i.setState({ showConfirmAppointAsMod: true });
1447   }
1448
1449   handleCancelConfirmAppointAsMod(i: CommentNode) {
1450     i.setState({ showConfirmAppointAsMod: false });
1451   }
1452
1453   handleAddModToCommunity(i: CommentNode) {
1454     let cv = i.props.node.comment_view;
1455     let auth = myAuth();
1456     if (auth) {
1457       let form: AddModToCommunity = {
1458         person_id: cv.creator.id,
1459         community_id: cv.community.id,
1460         added: !isMod(cv.creator.id, i.props.moderators),
1461         auth,
1462       };
1463       WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1464       i.setState({ showConfirmAppointAsMod: false });
1465     }
1466   }
1467
1468   handleShowConfirmAppointAsAdmin(i: CommentNode) {
1469     i.setState({ showConfirmAppointAsAdmin: true });
1470   }
1471
1472   handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1473     i.setState({ showConfirmAppointAsAdmin: false });
1474   }
1475
1476   handleAddAdmin(i: CommentNode) {
1477     let auth = myAuth();
1478     if (auth) {
1479       let creatorId = i.props.node.comment_view.creator.id;
1480       let form: AddAdmin = {
1481         person_id: creatorId,
1482         added: !isAdmin(creatorId, i.props.admins),
1483         auth,
1484       };
1485       WebSocketService.Instance.send(wsClient.addAdmin(form));
1486       i.setState({ showConfirmAppointAsAdmin: false });
1487     }
1488   }
1489
1490   handleShowConfirmTransferCommunity(i: CommentNode) {
1491     i.setState({ showConfirmTransferCommunity: true });
1492   }
1493
1494   handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1495     i.setState({ showConfirmTransferCommunity: false });
1496   }
1497
1498   handleTransferCommunity(i: CommentNode) {
1499     let cv = i.props.node.comment_view;
1500     let auth = myAuth();
1501     if (auth) {
1502       let form: TransferCommunity = {
1503         community_id: cv.community.id,
1504         person_id: cv.creator.id,
1505         auth,
1506       };
1507       WebSocketService.Instance.send(wsClient.transferCommunity(form));
1508       i.setState({ showConfirmTransferCommunity: false });
1509     }
1510   }
1511
1512   handleShowConfirmTransferSite(i: CommentNode) {
1513     i.setState({ showConfirmTransferSite: true });
1514   }
1515
1516   handleCancelShowConfirmTransferSite(i: CommentNode) {
1517     i.setState({ showConfirmTransferSite: false });
1518   }
1519
1520   get isCommentNew(): boolean {
1521     let now = moment.utc().subtract(10, "minutes");
1522     let then = moment.utc(this.props.node.comment_view.comment.published);
1523     return now.isBefore(then);
1524   }
1525
1526   handleCommentCollapse(i: CommentNode) {
1527     i.setState({ collapsed: !i.state.collapsed });
1528     setupTippy();
1529   }
1530
1531   handleViewSource(i: CommentNode) {
1532     i.setState({ viewSource: !i.state.viewSource });
1533   }
1534
1535   handleShowAdvanced(i: CommentNode) {
1536     i.setState({ showAdvanced: !i.state.showAdvanced });
1537     setupTippy();
1538   }
1539
1540   handleFetchChildren(i: CommentNode) {
1541     let form: GetComments = {
1542       post_id: i.props.node.comment_view.post.id,
1543       parent_id: i.props.node.comment_view.comment.id,
1544       max_depth: commentTreeMaxDepth,
1545       limit: 999, // TODO
1546       type_: ListingType.All,
1547       saved_only: false,
1548       auth: myAuth(false),
1549     };
1550
1551     WebSocketService.Instance.send(wsClient.getComments(form));
1552   }
1553
1554   get scoreColor() {
1555     if (this.state.my_vote == 1) {
1556       return "text-info";
1557     } else if (this.state.my_vote == -1) {
1558       return "text-danger";
1559     } else {
1560       return "text-muted";
1561     }
1562   }
1563
1564   get pointsTippy(): string {
1565     let points = i18n.t("number_of_points", {
1566       count: this.state.score,
1567       formattedCount: this.state.score,
1568     });
1569
1570     let upvotes = i18n.t("number_of_upvotes", {
1571       count: this.state.upvotes,
1572       formattedCount: this.state.upvotes,
1573     });
1574
1575     let downvotes = i18n.t("number_of_downvotes", {
1576       count: this.state.downvotes,
1577       formattedCount: this.state.downvotes,
1578     });
1579
1580     return `${points} • ${upvotes} • ${downvotes}`;
1581   }
1582
1583   get expandText(): string {
1584     return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
1585   }
1586 }