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