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