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