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