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