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