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