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