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