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