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