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