]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Merge branch 'main' into emoji-upload-button
[lemmy-ui.git] / src / shared / components / comment / comment-node.tsx
1 import {
2   colorList,
3   getCommentParentId,
4   myAuth,
5   myAuthRequired,
6   newVote,
7   showScores,
8 } from "@utils/app";
9 import { futureDaysToUnixTime, numToSI } from "@utils/helpers";
10 import {
11   amCommunityCreator,
12   canAdmin,
13   canMod,
14   isAdmin,
15   isBanned,
16   isMod,
17 } from "@utils/roles";
18 import classNames from "classnames";
19 import isBefore from "date-fns/isBefore";
20 import parseISO from "date-fns/parseISO";
21 import subMinutes from "date-fns/subMinutes";
22 import { Component, InfernoNode, linkEvent } from "inferno";
23 import { Link } from "inferno-router";
24 import {
25   AddAdmin,
26   AddModToCommunity,
27   BanFromCommunity,
28   BanPerson,
29   BlockPerson,
30   CommentId,
31   CommentReplyView,
32   CommentView,
33   CommunityModeratorView,
34   CreateComment,
35   CreateCommentLike,
36   CreateCommentReport,
37   DeleteComment,
38   DistinguishComment,
39   EditComment,
40   GetComments,
41   Language,
42   MarkCommentReplyAsRead,
43   MarkPersonMentionAsRead,
44   PersonMentionView,
45   PersonView,
46   PurgeComment,
47   PurgePerson,
48   RemoveComment,
49   SaveComment,
50   TransferCommunity,
51 } from "lemmy-js-client";
52 import deepEqual from "lodash.isequal";
53 import { commentTreeMaxDepth } from "../../config";
54 import {
55   BanType,
56   CommentNodeI,
57   CommentViewType,
58   PurgeType,
59   VoteType,
60 } from "../../interfaces";
61 import { mdToHtml, mdToHtmlNoImages } from "../../markdown";
62 import { I18NextService, UserService } from "../../services";
63 import { setupTippy } from "../../tippy";
64 import { Icon, PurgeWarning, Spinner } from "../common/icon";
65 import { MomentTime } from "../common/moment-time";
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" role="comment">
287         <article
288           id={`comment-${cv.comment.id}`}
289           className={classNames(`details comment-node py-2`, {
290             "border-top border-light": !this.props.noBorder,
291             mark: this.isCommentNew || this.commentView.comment.distinguished,
292           })}
293         >
294           <div
295             className={classNames({
296               "ms-2": !this.props.noIndent,
297             })}
298           >
299             <div className="d-flex flex-wrap align-items-center text-muted small">
300               <button
301                 className="btn btn-sm text-muted me-2"
302                 onClick={linkEvent(this, this.handleCommentCollapse)}
303                 aria-label={this.expandText}
304                 data-tippy-content={this.expandText}
305               >
306                 <Icon
307                   icon={`${this.state.collapsed ? "plus" : "minus"}-square`}
308                   classes="icon-inline"
309                 />
310               </button>
311               <span className="me-2">
312                 <PersonListing person={cv.creator} />
313               </span>
314               {cv.comment.distinguished && (
315                 <Icon icon="shield" inline classes={`text-danger me-2`} />
316               )}
317               {this.isPostCreator && (
318                 <div className="badge text-bg-light d-none d-sm-inline me-2">
319                   {I18NextService.i18n.t("creator")}
320                 </div>
321               )}
322               {isMod_ && (
323                 <div className="badge text-bg-light d-none d-sm-inline me-2">
324                   {I18NextService.i18n.t("mod")}
325                 </div>
326               )}
327               {isAdmin_ && (
328                 <div className="badge text-bg-light d-none d-sm-inline me-2">
329                   {I18NextService.i18n.t("admin")}
330                 </div>
331               )}
332               {cv.creator.bot_account && (
333                 <div className="badge text-bg-light d-none d-sm-inline me-2">
334                   {I18NextService.i18n.t("bot_account").toLowerCase()}
335                 </div>
336               )}
337               {this.props.showCommunity && (
338                 <>
339                   <span className="mx-1">{I18NextService.i18n.t("to")}</span>
340                   <CommunityLink community={cv.community} />
341                   <span className="mx-2">•</span>
342                   <Link className="me-2" to={`/post/${cv.post.id}`}>
343                     {cv.post.name}
344                   </Link>
345                 </>
346               )}
347               {this.linkBtn(true)}
348               {cv.comment.language_id !== 0 && (
349                 <span className="badge text-bg-light d-none d-sm-inline me-2">
350                   {
351                     this.props.allLanguages.find(
352                       lang => lang.id === cv.comment.language_id
353                     )?.name
354                   }
355                 </span>
356               )}
357               {/* This is an expanding spacer for mobile */}
358               <div className="me-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2" />
359               {showScores() && (
360                 <>
361                   <a
362                     className={`unselectable pointer ${this.scoreColor}`}
363                     onClick={linkEvent(this, this.handleUpvote)}
364                     data-tippy-content={this.pointsTippy}
365                   >
366                     {this.state.upvoteLoading ? (
367                       <Spinner />
368                     ) : (
369                       <span
370                         className="me-1 font-weight-bold"
371                         aria-label={I18NextService.i18n.t("number_of_points", {
372                           count: Number(this.commentView.counts.score),
373                           formattedCount: numToSI(
374                             this.commentView.counts.score
375                           ),
376                         })}
377                       >
378                         {numToSI(this.commentView.counts.score)}
379                       </span>
380                     )}
381                   </a>
382                   <span className="me-1">•</span>
383                 </>
384               )}
385               <span>
386                 <MomentTime
387                   published={cv.comment.published}
388                   updated={cv.comment.updated}
389                 />
390               </span>
391             </div>
392             {/* end of user row */}
393             {this.state.showEdit && (
394               <CommentForm
395                 node={node}
396                 edit
397                 onReplyCancel={this.handleReplyCancel}
398                 disabled={this.props.locked}
399                 finished={this.props.finished.get(
400                   this.props.node.comment_view.comment.id
401                 )}
402                 focus
403                 allLanguages={this.props.allLanguages}
404                 siteLanguages={this.props.siteLanguages}
405                 containerClass="comment-comment-container"
406                 onUpsertComment={this.props.onEditComment}
407               />
408             )}
409             {!this.state.showEdit && !this.state.collapsed && (
410               <div>
411                 {this.state.viewSource ? (
412                   <pre>{this.commentUnlessRemoved}</pre>
413                 ) : (
414                   <div
415                     className="md-div"
416                     dangerouslySetInnerHTML={
417                       this.props.hideImages
418                         ? mdToHtmlNoImages(this.commentUnlessRemoved)
419                         : mdToHtml(this.commentUnlessRemoved)
420                     }
421                   />
422                 )}
423                 <div className="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
424                   {this.props.showContext && this.linkBtn()}
425                   {this.props.markable && (
426                     <button
427                       className="btn btn-link btn-animate text-muted"
428                       onClick={linkEvent(this, this.handleMarkAsRead)}
429                       data-tippy-content={
430                         this.commentReplyOrMentionRead
431                           ? I18NextService.i18n.t("mark_as_unread")
432                           : I18NextService.i18n.t("mark_as_read")
433                       }
434                       aria-label={
435                         this.commentReplyOrMentionRead
436                           ? I18NextService.i18n.t("mark_as_unread")
437                           : I18NextService.i18n.t("mark_as_read")
438                       }
439                     >
440                       {this.state.readLoading ? (
441                         <Spinner />
442                       ) : (
443                         <Icon
444                           icon="check"
445                           classes={`icon-inline ${
446                             this.commentReplyOrMentionRead && "text-success"
447                           }`}
448                         />
449                       )}
450                     </button>
451                   )}
452                   {UserService.Instance.myUserInfo && !this.props.viewOnly && (
453                     <>
454                       <button
455                         className={`btn btn-link btn-animate ${
456                           this.commentView.my_vote === 1
457                             ? "text-info"
458                             : "text-muted"
459                         }`}
460                         onClick={linkEvent(this, this.handleUpvote)}
461                         data-tippy-content={I18NextService.i18n.t("upvote")}
462                         aria-label={I18NextService.i18n.t("upvote")}
463                         aria-pressed={this.commentView.my_vote === 1}
464                       >
465                         {this.state.upvoteLoading ? (
466                           <Spinner />
467                         ) : (
468                           <>
469                             <Icon icon="arrow-up1" classes="icon-inline" />
470                             {showScores() &&
471                               this.commentView.counts.upvotes !==
472                                 this.commentView.counts.score && (
473                                 <span className="ms-1">
474                                   {numToSI(this.commentView.counts.upvotes)}
475                                 </span>
476                               )}
477                           </>
478                         )}
479                       </button>
480                       {this.props.enableDownvotes && (
481                         <button
482                           className={`btn btn-link btn-animate ${
483                             this.commentView.my_vote === -1
484                               ? "text-danger"
485                               : "text-muted"
486                           }`}
487                           onClick={linkEvent(this, this.handleDownvote)}
488                           data-tippy-content={I18NextService.i18n.t("downvote")}
489                           aria-label={I18NextService.i18n.t("downvote")}
490                           aria-pressed={this.commentView.my_vote === -1}
491                         >
492                           {this.state.downvoteLoading ? (
493                             <Spinner />
494                           ) : (
495                             <>
496                               <Icon icon="arrow-down1" classes="icon-inline" />
497                               {showScores() &&
498                                 this.commentView.counts.upvotes !==
499                                   this.commentView.counts.score && (
500                                   <span className="ms-1">
501                                     {numToSI(this.commentView.counts.downvotes)}
502                                   </span>
503                                 )}
504                             </>
505                           )}
506                         </button>
507                       )}
508                       <button
509                         className="btn btn-link btn-animate text-muted"
510                         onClick={linkEvent(this, this.handleReplyClick)}
511                         data-tippy-content={I18NextService.i18n.t("reply")}
512                         aria-label={I18NextService.i18n.t("reply")}
513                       >
514                         <Icon icon="reply1" classes="icon-inline" />
515                       </button>
516                       {!this.state.showAdvanced ? (
517                         <button
518                           className="btn btn-link btn-animate text-muted btn-more"
519                           onClick={linkEvent(this, this.handleShowAdvanced)}
520                           data-tippy-content={I18NextService.i18n.t("more")}
521                           aria-label={I18NextService.i18n.t("more")}
522                         >
523                           <Icon icon="more-vertical" classes="icon-inline" />
524                         </button>
525                       ) : (
526                         <>
527                           {!this.myComment && (
528                             <>
529                               <Link
530                                 className="btn btn-link btn-animate text-muted"
531                                 to={`/create_private_message/${cv.creator.id}`}
532                                 title={I18NextService.i18n
533                                   .t("message")
534                                   .toLowerCase()}
535                               >
536                                 <Icon icon="mail" />
537                               </Link>
538                               <button
539                                 className="btn btn-link btn-animate text-muted"
540                                 onClick={linkEvent(
541                                   this,
542                                   this.handleShowReportDialog
543                                 )}
544                                 data-tippy-content={I18NextService.i18n.t(
545                                   "show_report_dialog"
546                                 )}
547                                 aria-label={I18NextService.i18n.t(
548                                   "show_report_dialog"
549                                 )}
550                               >
551                                 <Icon icon="flag" />
552                               </button>
553                               <button
554                                 className="btn btn-link btn-animate text-muted"
555                                 onClick={linkEvent(
556                                   this,
557                                   this.handleBlockPerson
558                                 )}
559                                 data-tippy-content={I18NextService.i18n.t(
560                                   "block_user"
561                                 )}
562                                 aria-label={I18NextService.i18n.t("block_user")}
563                               >
564                                 {this.state.blockPersonLoading ? (
565                                   <Spinner />
566                                 ) : (
567                                   <Icon icon="slash" />
568                                 )}
569                               </button>
570                             </>
571                           )}
572                           <button
573                             className="btn btn-link btn-animate text-muted"
574                             onClick={linkEvent(this, this.handleSaveComment)}
575                             data-tippy-content={
576                               cv.saved
577                                 ? I18NextService.i18n.t("unsave")
578                                 : I18NextService.i18n.t("save")
579                             }
580                             aria-label={
581                               cv.saved
582                                 ? I18NextService.i18n.t("unsave")
583                                 : I18NextService.i18n.t("save")
584                             }
585                           >
586                             {this.state.saveLoading ? (
587                               <Spinner />
588                             ) : (
589                               <Icon
590                                 icon="star"
591                                 classes={`icon-inline ${
592                                   cv.saved && "text-warning"
593                                 }`}
594                               />
595                             )}
596                           </button>
597                           <button
598                             className="btn btn-link btn-animate text-muted"
599                             onClick={linkEvent(this, this.handleViewSource)}
600                             data-tippy-content={I18NextService.i18n.t(
601                               "view_source"
602                             )}
603                             aria-label={I18NextService.i18n.t("view_source")}
604                           >
605                             <Icon
606                               icon="file-text"
607                               classes={`icon-inline ${
608                                 this.state.viewSource && "text-success"
609                               }`}
610                             />
611                           </button>
612                           {this.myComment && (
613                             <>
614                               <button
615                                 className="btn btn-link btn-animate text-muted"
616                                 onClick={linkEvent(this, this.handleEditClick)}
617                                 data-tippy-content={I18NextService.i18n.t(
618                                   "edit"
619                                 )}
620                                 aria-label={I18NextService.i18n.t("edit")}
621                               >
622                                 <Icon icon="edit" classes="icon-inline" />
623                               </button>
624                               <button
625                                 className="btn btn-link btn-animate text-muted"
626                                 onClick={linkEvent(
627                                   this,
628                                   this.handleDeleteComment
629                                 )}
630                                 data-tippy-content={
631                                   !cv.comment.deleted
632                                     ? I18NextService.i18n.t("delete")
633                                     : I18NextService.i18n.t("restore")
634                                 }
635                                 aria-label={
636                                   !cv.comment.deleted
637                                     ? I18NextService.i18n.t("delete")
638                                     : I18NextService.i18n.t("restore")
639                                 }
640                               >
641                                 {this.state.deleteLoading ? (
642                                   <Spinner />
643                                 ) : (
644                                   <Icon
645                                     icon="trash"
646                                     classes={`icon-inline ${
647                                       cv.comment.deleted && "text-danger"
648                                     }`}
649                                   />
650                                 )}
651                               </button>
652
653                               {(canModOnSelf || canAdminOnSelf) && (
654                                 <button
655                                   className="btn btn-link btn-animate text-muted"
656                                   onClick={linkEvent(
657                                     this,
658                                     this.handleDistinguishComment
659                                   )}
660                                   data-tippy-content={
661                                     !cv.comment.distinguished
662                                       ? I18NextService.i18n.t("distinguish")
663                                       : I18NextService.i18n.t("undistinguish")
664                                   }
665                                   aria-label={
666                                     !cv.comment.distinguished
667                                       ? I18NextService.i18n.t("distinguish")
668                                       : I18NextService.i18n.t("undistinguish")
669                                   }
670                                 >
671                                   <Icon
672                                     icon="shield"
673                                     classes={`icon-inline ${
674                                       cv.comment.distinguished && "text-danger"
675                                     }`}
676                                   />
677                                 </button>
678                               )}
679                             </>
680                           )}
681                           {/* Admins and mods can remove comments */}
682                           {(canMod_ || canAdmin_) && (
683                             <>
684                               {!cv.comment.removed ? (
685                                 <button
686                                   className="btn btn-link btn-animate text-muted"
687                                   onClick={linkEvent(
688                                     this,
689                                     this.handleModRemoveShow
690                                   )}
691                                   aria-label={I18NextService.i18n.t("remove")}
692                                 >
693                                   {I18NextService.i18n.t("remove")}
694                                 </button>
695                               ) : (
696                                 <button
697                                   className="btn btn-link btn-animate text-muted"
698                                   onClick={linkEvent(
699                                     this,
700                                     this.handleRemoveComment
701                                   )}
702                                   aria-label={I18NextService.i18n.t("restore")}
703                                 >
704                                   {this.state.removeLoading ? (
705                                     <Spinner />
706                                   ) : (
707                                     I18NextService.i18n.t("restore")
708                                   )}
709                                 </button>
710                               )}
711                             </>
712                           )}
713                           {/* Mods can ban from community, and appoint as mods to community */}
714                           {canMod_ && (
715                             <>
716                               {!isMod_ &&
717                                 (!cv.creator_banned_from_community ? (
718                                   <button
719                                     className="btn btn-link btn-animate text-muted"
720                                     onClick={linkEvent(
721                                       this,
722                                       this.handleModBanFromCommunityShow
723                                     )}
724                                     aria-label={I18NextService.i18n.t(
725                                       "ban_from_community"
726                                     )}
727                                   >
728                                     {I18NextService.i18n.t(
729                                       "ban_from_community"
730                                     )}
731                                   </button>
732                                 ) : (
733                                   <button
734                                     className="btn btn-link btn-animate text-muted"
735                                     onClick={linkEvent(
736                                       this,
737                                       this.handleBanPersonFromCommunity
738                                     )}
739                                     aria-label={I18NextService.i18n.t("unban")}
740                                   >
741                                     {this.state.banLoading ? (
742                                       <Spinner />
743                                     ) : (
744                                       I18NextService.i18n.t("unban")
745                                     )}
746                                   </button>
747                                 ))}
748                               {!cv.creator_banned_from_community &&
749                                 (!this.state.showConfirmAppointAsMod ? (
750                                   <button
751                                     className="btn btn-link btn-animate text-muted"
752                                     onClick={linkEvent(
753                                       this,
754                                       this.handleShowConfirmAppointAsMod
755                                     )}
756                                     aria-label={
757                                       isMod_
758                                         ? I18NextService.i18n.t("remove_as_mod")
759                                         : I18NextService.i18n.t(
760                                             "appoint_as_mod"
761                                           )
762                                     }
763                                   >
764                                     {isMod_
765                                       ? I18NextService.i18n.t("remove_as_mod")
766                                       : I18NextService.i18n.t("appoint_as_mod")}
767                                   </button>
768                                 ) : (
769                                   <>
770                                     <button
771                                       className="btn btn-link btn-animate text-muted"
772                                       aria-label={I18NextService.i18n.t(
773                                         "are_you_sure"
774                                       )}
775                                     >
776                                       {I18NextService.i18n.t("are_you_sure")}
777                                     </button>
778                                     <button
779                                       className="btn btn-link btn-animate text-muted"
780                                       onClick={linkEvent(
781                                         this,
782                                         this.handleAddModToCommunity
783                                       )}
784                                       aria-label={I18NextService.i18n.t("yes")}
785                                     >
786                                       {this.state.addModLoading ? (
787                                         <Spinner />
788                                       ) : (
789                                         I18NextService.i18n.t("yes")
790                                       )}
791                                     </button>
792                                     <button
793                                       className="btn btn-link btn-animate text-muted"
794                                       onClick={linkEvent(
795                                         this,
796                                         this.handleCancelConfirmAppointAsMod
797                                       )}
798                                       aria-label={I18NextService.i18n.t("no")}
799                                     >
800                                       {I18NextService.i18n.t("no")}
801                                     </button>
802                                   </>
803                                 ))}
804                             </>
805                           )}
806                           {/* Community creators and admins can transfer community to another mod */}
807                           {(amCommunityCreator_ || canAdmin_) &&
808                             isMod_ &&
809                             cv.creator.local &&
810                             (!this.state.showConfirmTransferCommunity ? (
811                               <button
812                                 className="btn btn-link btn-animate text-muted"
813                                 onClick={linkEvent(
814                                   this,
815                                   this.handleShowConfirmTransferCommunity
816                                 )}
817                                 aria-label={I18NextService.i18n.t(
818                                   "transfer_community"
819                                 )}
820                               >
821                                 {I18NextService.i18n.t("transfer_community")}
822                               </button>
823                             ) : (
824                               <>
825                                 <button
826                                   className="btn btn-link btn-animate text-muted"
827                                   aria-label={I18NextService.i18n.t(
828                                     "are_you_sure"
829                                   )}
830                                 >
831                                   {I18NextService.i18n.t("are_you_sure")}
832                                 </button>
833                                 <button
834                                   className="btn btn-link btn-animate text-muted"
835                                   onClick={linkEvent(
836                                     this,
837                                     this.handleTransferCommunity
838                                   )}
839                                   aria-label={I18NextService.i18n.t("yes")}
840                                 >
841                                   {this.state.transferCommunityLoading ? (
842                                     <Spinner />
843                                   ) : (
844                                     I18NextService.i18n.t("yes")
845                                   )}
846                                 </button>
847                                 <button
848                                   className="btn btn-link btn-animate text-muted"
849                                   onClick={linkEvent(
850                                     this,
851                                     this
852                                       .handleCancelShowConfirmTransferCommunity
853                                   )}
854                                   aria-label={I18NextService.i18n.t("no")}
855                                 >
856                                   {I18NextService.i18n.t("no")}
857                                 </button>
858                               </>
859                             ))}
860                           {/* Admins can ban from all, and appoint other admins */}
861                           {canAdmin_ && (
862                             <>
863                               {!isAdmin_ && (
864                                 <>
865                                   <button
866                                     className="btn btn-link btn-animate text-muted"
867                                     onClick={linkEvent(
868                                       this,
869                                       this.handlePurgePersonShow
870                                     )}
871                                     aria-label={I18NextService.i18n.t(
872                                       "purge_user"
873                                     )}
874                                   >
875                                     {I18NextService.i18n.t("purge_user")}
876                                   </button>
877                                   <button
878                                     className="btn btn-link btn-animate text-muted"
879                                     onClick={linkEvent(
880                                       this,
881                                       this.handlePurgeCommentShow
882                                     )}
883                                     aria-label={I18NextService.i18n.t(
884                                       "purge_comment"
885                                     )}
886                                   >
887                                     {I18NextService.i18n.t("purge_comment")}
888                                   </button>
889
890                                   {!isBanned(cv.creator) ? (
891                                     <button
892                                       className="btn btn-link btn-animate text-muted"
893                                       onClick={linkEvent(
894                                         this,
895                                         this.handleModBanShow
896                                       )}
897                                       aria-label={I18NextService.i18n.t(
898                                         "ban_from_site"
899                                       )}
900                                     >
901                                       {I18NextService.i18n.t("ban_from_site")}
902                                     </button>
903                                   ) : (
904                                     <button
905                                       className="btn btn-link btn-animate text-muted"
906                                       onClick={linkEvent(
907                                         this,
908                                         this.handleBanPerson
909                                       )}
910                                       aria-label={I18NextService.i18n.t(
911                                         "unban_from_site"
912                                       )}
913                                     >
914                                       {this.state.banLoading ? (
915                                         <Spinner />
916                                       ) : (
917                                         I18NextService.i18n.t("unban_from_site")
918                                       )}
919                                     </button>
920                                   )}
921                                 </>
922                               )}
923                               {!isBanned(cv.creator) &&
924                                 cv.creator.local &&
925                                 (!this.state.showConfirmAppointAsAdmin ? (
926                                   <button
927                                     className="btn btn-link btn-animate text-muted"
928                                     onClick={linkEvent(
929                                       this,
930                                       this.handleShowConfirmAppointAsAdmin
931                                     )}
932                                     aria-label={
933                                       isAdmin_
934                                         ? I18NextService.i18n.t(
935                                             "remove_as_admin"
936                                           )
937                                         : I18NextService.i18n.t(
938                                             "appoint_as_admin"
939                                           )
940                                     }
941                                   >
942                                     {isAdmin_
943                                       ? I18NextService.i18n.t("remove_as_admin")
944                                       : I18NextService.i18n.t(
945                                           "appoint_as_admin"
946                                         )}
947                                   </button>
948                                 ) : (
949                                   <>
950                                     <button className="btn btn-link btn-animate text-muted">
951                                       {I18NextService.i18n.t("are_you_sure")}
952                                     </button>
953                                     <button
954                                       className="btn btn-link btn-animate text-muted"
955                                       onClick={linkEvent(
956                                         this,
957                                         this.handleAddAdmin
958                                       )}
959                                       aria-label={I18NextService.i18n.t("yes")}
960                                     >
961                                       {this.state.addAdminLoading ? (
962                                         <Spinner />
963                                       ) : (
964                                         I18NextService.i18n.t("yes")
965                                       )}
966                                     </button>
967                                     <button
968                                       className="btn btn-link btn-animate text-muted"
969                                       onClick={linkEvent(
970                                         this,
971                                         this.handleCancelConfirmAppointAsAdmin
972                                       )}
973                                       aria-label={I18NextService.i18n.t("no")}
974                                     >
975                                       {I18NextService.i18n.t("no")}
976                                     </button>
977                                   </>
978                                 ))}
979                             </>
980                           )}
981                         </>
982                       )}
983                     </>
984                   )}
985                 </div>
986                 {/* end of button group */}
987               </div>
988             )}
989           </div>
990         </article>
991         {showMoreChildren && (
992           <div
993             className={classNames("details ms-1 comment-node py-2", {
994               "border-top border-light": !this.props.noBorder,
995             })}
996             style={`border-left: 2px ${moreRepliesBorderColor} solid !important`}
997           >
998             <button
999               className="btn btn-link text-muted"
1000               onClick={linkEvent(this, this.handleFetchChildren)}
1001             >
1002               {this.state.fetchChildrenLoading ? (
1003                 <Spinner />
1004               ) : (
1005                 <>
1006                   {I18NextService.i18n.t("x_more_replies", {
1007                     count: node.comment_view.counts.child_count,
1008                     formattedCount: numToSI(
1009                       node.comment_view.counts.child_count
1010                     ),
1011                   })}{" "}
1012                   âž”
1013                 </>
1014               )}
1015             </button>
1016           </div>
1017         )}
1018         {/* end of details */}
1019         {this.state.showRemoveDialog && (
1020           <form
1021             className="form-inline"
1022             onSubmit={linkEvent(this, this.handleRemoveComment)}
1023           >
1024             <label
1025               className="visually-hidden"
1026               htmlFor={`mod-remove-reason-${cv.comment.id}`}
1027             >
1028               {I18NextService.i18n.t("reason")}
1029             </label>
1030             <input
1031               type="text"
1032               id={`mod-remove-reason-${cv.comment.id}`}
1033               className="form-control me-2"
1034               placeholder={I18NextService.i18n.t("reason")}
1035               value={this.state.removeReason}
1036               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
1037             />
1038             <button
1039               type="submit"
1040               className="btn btn-secondary"
1041               aria-label={I18NextService.i18n.t("remove_comment")}
1042             >
1043               {I18NextService.i18n.t("remove_comment")}
1044             </button>
1045           </form>
1046         )}
1047         {this.state.showReportDialog && (
1048           <form
1049             className="form-inline"
1050             onSubmit={linkEvent(this, this.handleReportComment)}
1051           >
1052             <label
1053               className="visually-hidden"
1054               htmlFor={`report-reason-${cv.comment.id}`}
1055             >
1056               {I18NextService.i18n.t("reason")}
1057             </label>
1058             <input
1059               type="text"
1060               required
1061               id={`report-reason-${cv.comment.id}`}
1062               className="form-control me-2"
1063               placeholder={I18NextService.i18n.t("reason")}
1064               value={this.state.reportReason}
1065               onInput={linkEvent(this, this.handleReportReasonChange)}
1066             />
1067             <button
1068               type="submit"
1069               className="btn btn-secondary"
1070               aria-label={I18NextService.i18n.t("create_report")}
1071             >
1072               {I18NextService.i18n.t("create_report")}
1073             </button>
1074           </form>
1075         )}
1076         {this.state.showBanDialog && (
1077           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
1078             <div className="mb-3 row col-12">
1079               <label
1080                 className="col-form-label"
1081                 htmlFor={`mod-ban-reason-${cv.comment.id}`}
1082               >
1083                 {I18NextService.i18n.t("reason")}
1084               </label>
1085               <input
1086                 type="text"
1087                 id={`mod-ban-reason-${cv.comment.id}`}
1088                 className="form-control me-2"
1089                 placeholder={I18NextService.i18n.t("reason")}
1090                 value={this.state.banReason}
1091                 onInput={linkEvent(this, this.handleModBanReasonChange)}
1092               />
1093               <label
1094                 className="col-form-label"
1095                 htmlFor={`mod-ban-expires-${cv.comment.id}`}
1096               >
1097                 {I18NextService.i18n.t("expires")}
1098               </label>
1099               <input
1100                 type="number"
1101                 id={`mod-ban-expires-${cv.comment.id}`}
1102                 className="form-control me-2"
1103                 placeholder={I18NextService.i18n.t("number_of_days")}
1104                 value={this.state.banExpireDays}
1105                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
1106               />
1107               <div className="input-group mb-3">
1108                 <div className="form-check">
1109                   <input
1110                     className="form-check-input"
1111                     id="mod-ban-remove-data"
1112                     type="checkbox"
1113                     checked={this.state.removeData}
1114                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
1115                   />
1116                   <label
1117                     className="form-check-label"
1118                     htmlFor="mod-ban-remove-data"
1119                     title={I18NextService.i18n.t("remove_content_more")}
1120                   >
1121                     {I18NextService.i18n.t("remove_content")}
1122                   </label>
1123                 </div>
1124               </div>
1125             </div>
1126             {/* TODO hold off on expires until later */}
1127             {/* <div class="mb-3 row"> */}
1128             {/*   <label class="col-form-label">Expires</label> */}
1129             {/*   <input type="date" class="form-control me-2" placeholder={I18NextService.i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
1130             {/* </div> */}
1131             <div className="mb-3 row">
1132               <button
1133                 type="submit"
1134                 className="btn btn-secondary"
1135                 aria-label={I18NextService.i18n.t("ban")}
1136               >
1137                 {this.state.banLoading ? (
1138                   <Spinner />
1139                 ) : (
1140                   <span>
1141                     {I18NextService.i18n.t("ban")} {cv.creator.name}
1142                   </span>
1143                 )}
1144               </button>
1145             </div>
1146           </form>
1147         )}
1148
1149         {this.state.showPurgeDialog && (
1150           <form onSubmit={linkEvent(this, this.handlePurgeBothSubmit)}>
1151             <PurgeWarning />
1152             <label className="visually-hidden" htmlFor="purge-reason">
1153               {I18NextService.i18n.t("reason")}
1154             </label>
1155             <input
1156               type="text"
1157               id="purge-reason"
1158               className="form-control my-3"
1159               placeholder={I18NextService.i18n.t("reason")}
1160               value={this.state.purgeReason}
1161               onInput={linkEvent(this, this.handlePurgeReasonChange)}
1162             />
1163             <div className="mb-3 row col-12">
1164               {this.state.purgeLoading ? (
1165                 <Spinner />
1166               ) : (
1167                 <button
1168                   type="submit"
1169                   className="btn btn-secondary"
1170                   aria-label={purgeTypeText}
1171                 >
1172                   {purgeTypeText}
1173                 </button>
1174               )}
1175             </div>
1176           </form>
1177         )}
1178         {this.state.showReply && (
1179           <CommentForm
1180             node={node}
1181             onReplyCancel={this.handleReplyCancel}
1182             disabled={this.props.locked}
1183             finished={this.props.finished.get(
1184               this.props.node.comment_view.comment.id
1185             )}
1186             focus
1187             allLanguages={this.props.allLanguages}
1188             siteLanguages={this.props.siteLanguages}
1189             containerClass="comment-comment-container"
1190             onUpsertComment={this.props.onCreateComment}
1191           />
1192         )}
1193         {!this.state.collapsed && node.children.length > 0 && (
1194           <CommentNodes
1195             nodes={node.children}
1196             locked={this.props.locked}
1197             moderators={this.props.moderators}
1198             admins={this.props.admins}
1199             enableDownvotes={this.props.enableDownvotes}
1200             viewType={this.props.viewType}
1201             allLanguages={this.props.allLanguages}
1202             siteLanguages={this.props.siteLanguages}
1203             hideImages={this.props.hideImages}
1204             isChild={!this.props.noIndent}
1205             depth={this.props.node.depth + 1}
1206             finished={this.props.finished}
1207             onCommentReplyRead={this.props.onCommentReplyRead}
1208             onPersonMentionRead={this.props.onPersonMentionRead}
1209             onCreateComment={this.props.onCreateComment}
1210             onEditComment={this.props.onEditComment}
1211             onCommentVote={this.props.onCommentVote}
1212             onBlockPerson={this.props.onBlockPerson}
1213             onSaveComment={this.props.onSaveComment}
1214             onDeleteComment={this.props.onDeleteComment}
1215             onRemoveComment={this.props.onRemoveComment}
1216             onDistinguishComment={this.props.onDistinguishComment}
1217             onAddModToCommunity={this.props.onAddModToCommunity}
1218             onAddAdmin={this.props.onAddAdmin}
1219             onBanPersonFromCommunity={this.props.onBanPersonFromCommunity}
1220             onBanPerson={this.props.onBanPerson}
1221             onTransferCommunity={this.props.onTransferCommunity}
1222             onFetchChildren={this.props.onFetchChildren}
1223             onCommentReport={this.props.onCommentReport}
1224             onPurgePerson={this.props.onPurgePerson}
1225             onPurgeComment={this.props.onPurgeComment}
1226           />
1227         )}
1228         {/* A collapsed clearfix */}
1229         {this.state.collapsed && <div className="row col-12" />}
1230       </li>
1231     );
1232   }
1233
1234   get commentReplyOrMentionRead(): boolean {
1235     const cv = this.commentView;
1236
1237     if (this.isPersonMentionType(cv)) {
1238       return cv.person_mention.read;
1239     } else if (this.isCommentReplyType(cv)) {
1240       return cv.comment_reply.read;
1241     } else {
1242       return false;
1243     }
1244   }
1245
1246   linkBtn(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   handleUpvote(i: CommentNode) {
1487     i.setState({ upvoteLoading: true });
1488     i.props.onCommentVote({
1489       comment_id: i.commentId,
1490       score: newVote(VoteType.Upvote, i.commentView.my_vote),
1491       auth: myAuthRequired(),
1492     });
1493   }
1494
1495   handleDownvote(i: CommentNode) {
1496     i.setState({ downvoteLoading: true });
1497     i.props.onCommentVote({
1498       comment_id: i.commentId,
1499       score: newVote(VoteType.Downvote, i.commentView.my_vote),
1500       auth: myAuthRequired(),
1501     });
1502   }
1503
1504   handleBlockPerson(i: CommentNode) {
1505     i.setState({ blockPersonLoading: true });
1506     i.props.onBlockPerson({
1507       person_id: i.commentView.creator.id,
1508       block: true,
1509       auth: myAuthRequired(),
1510     });
1511   }
1512
1513   handleMarkAsRead(i: CommentNode) {
1514     i.setState({ readLoading: true });
1515     const cv = i.commentView;
1516     if (i.isPersonMentionType(cv)) {
1517       i.props.onPersonMentionRead({
1518         person_mention_id: cv.person_mention.id,
1519         read: !cv.person_mention.read,
1520         auth: myAuthRequired(),
1521       });
1522     } else if (i.isCommentReplyType(cv)) {
1523       i.props.onCommentReplyRead({
1524         comment_reply_id: cv.comment_reply.id,
1525         read: !cv.comment_reply.read,
1526         auth: myAuthRequired(),
1527       });
1528     }
1529   }
1530
1531   handleDeleteComment(i: CommentNode) {
1532     i.setState({ deleteLoading: true });
1533     i.props.onDeleteComment({
1534       comment_id: i.commentId,
1535       deleted: !i.commentView.comment.deleted,
1536       auth: myAuthRequired(),
1537     });
1538   }
1539
1540   handleRemoveComment(i: CommentNode, event: any) {
1541     event.preventDefault();
1542     i.setState({ removeLoading: true });
1543     i.props.onRemoveComment({
1544       comment_id: i.commentId,
1545       removed: !i.commentView.comment.removed,
1546       auth: myAuthRequired(),
1547     });
1548   }
1549
1550   handleDistinguishComment(i: CommentNode) {
1551     i.setState({ distinguishLoading: true });
1552     i.props.onDistinguishComment({
1553       comment_id: i.commentId,
1554       distinguished: !i.commentView.comment.distinguished,
1555       auth: myAuthRequired(),
1556     });
1557   }
1558
1559   handleBanPersonFromCommunity(i: CommentNode) {
1560     i.setState({ banLoading: true });
1561     i.props.onBanPersonFromCommunity({
1562       community_id: i.commentView.community.id,
1563       person_id: i.commentView.creator.id,
1564       ban: !i.commentView.creator_banned_from_community,
1565       reason: i.state.banReason,
1566       remove_data: i.state.removeData,
1567       expires: futureDaysToUnixTime(i.state.banExpireDays),
1568       auth: myAuthRequired(),
1569     });
1570   }
1571
1572   handleBanPerson(i: CommentNode) {
1573     i.setState({ banLoading: true });
1574     i.props.onBanPerson({
1575       person_id: i.commentView.creator.id,
1576       ban: !i.commentView.creator_banned_from_community,
1577       reason: i.state.banReason,
1578       remove_data: i.state.removeData,
1579       expires: futureDaysToUnixTime(i.state.banExpireDays),
1580       auth: myAuthRequired(),
1581     });
1582   }
1583
1584   handleModBanBothSubmit(i: CommentNode, event: any) {
1585     event.preventDefault();
1586     if (i.state.banType == BanType.Community) {
1587       i.handleBanPersonFromCommunity(i);
1588     } else {
1589       i.handleBanPerson(i);
1590     }
1591   }
1592
1593   handleAddModToCommunity(i: CommentNode) {
1594     i.setState({ addModLoading: true });
1595
1596     const added = !isMod(i.commentView.comment.creator_id, i.props.moderators);
1597     i.props.onAddModToCommunity({
1598       community_id: i.commentView.community.id,
1599       person_id: i.commentView.creator.id,
1600       added,
1601       auth: myAuthRequired(),
1602     });
1603   }
1604
1605   handleAddAdmin(i: CommentNode) {
1606     i.setState({ addAdminLoading: true });
1607
1608     const added = !isAdmin(i.commentView.comment.creator_id, i.props.admins);
1609     i.props.onAddAdmin({
1610       person_id: i.commentView.creator.id,
1611       added,
1612       auth: myAuthRequired(),
1613     });
1614   }
1615
1616   handleTransferCommunity(i: CommentNode) {
1617     i.setState({ transferCommunityLoading: true });
1618     i.props.onTransferCommunity({
1619       community_id: i.commentView.community.id,
1620       person_id: i.commentView.creator.id,
1621       auth: myAuthRequired(),
1622     });
1623   }
1624
1625   handleReportComment(i: CommentNode, event: any) {
1626     event.preventDefault();
1627     i.setState({ reportLoading: true });
1628     i.props.onCommentReport({
1629       comment_id: i.commentId,
1630       reason: i.state.reportReason ?? "",
1631       auth: myAuthRequired(),
1632     });
1633   }
1634
1635   handlePurgeBothSubmit(i: CommentNode, event: any) {
1636     event.preventDefault();
1637     i.setState({ purgeLoading: true });
1638
1639     if (i.state.purgeType == PurgeType.Person) {
1640       i.props.onPurgePerson({
1641         person_id: i.commentView.creator.id,
1642         reason: i.state.purgeReason,
1643         auth: myAuthRequired(),
1644       });
1645     } else {
1646       i.props.onPurgeComment({
1647         comment_id: i.commentId,
1648         reason: i.state.purgeReason,
1649         auth: myAuthRequired(),
1650       });
1651     }
1652   }
1653
1654   handleFetchChildren(i: CommentNode) {
1655     i.setState({ fetchChildrenLoading: true });
1656     i.props.onFetchChildren?.({
1657       parent_id: i.commentId,
1658       max_depth: commentTreeMaxDepth,
1659       limit: 999, // TODO
1660       type_: "All",
1661       saved_only: false,
1662       auth: myAuth(),
1663     });
1664   }
1665 }