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