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