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