]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Reporting (#434)
[lemmy-ui.git] / src / shared / components / comment / comment-node.tsx
1 import { Component, linkEvent } from "inferno";
2 import { Link } from "inferno-router";
3 import {
4   AddAdmin,
5   AddModToCommunity,
6   BanFromCommunity,
7   BanPerson,
8   BlockPerson,
9   CommentView,
10   CommunityModeratorView,
11   CreateCommentLike,
12   CreateCommentReport,
13   DeleteComment,
14   MarkCommentAsRead,
15   MarkPersonMentionAsRead,
16   PersonMentionView,
17   PersonViewSafe,
18   RemoveComment,
19   SaveComment,
20   TransferCommunity,
21   TransferSite,
22 } from "lemmy-js-client";
23 import moment from "moment";
24 import { i18n } from "../../i18next";
25 import { BanType, CommentNode as CommentNodeI } from "../../interfaces";
26 import { UserService, WebSocketService } from "../../services";
27 import {
28   authField,
29   canMod,
30   colorList,
31   getUnixTime,
32   isMod,
33   mdToHtml,
34   numToSI,
35   setupTippy,
36   showScores,
37   wsClient,
38 } from "../../utils";
39 import { Icon, Spinner } from "../common/icon";
40 import { MomentTime } from "../common/moment-time";
41 import { CommunityLink } from "../community/community-link";
42 import { PersonListing } from "../person/person-listing";
43 import { CommentForm } from "./comment-form";
44 import { CommentNodes } from "./comment-nodes";
45
46 interface CommentNodeState {
47   showReply: boolean;
48   showEdit: boolean;
49   showRemoveDialog: boolean;
50   removeReason: string;
51   showBanDialog: boolean;
52   removeData: boolean;
53   banReason: string;
54   banExpires: string;
55   banType: BanType;
56   showConfirmTransferSite: boolean;
57   showConfirmTransferCommunity: boolean;
58   showConfirmAppointAsMod: boolean;
59   showConfirmAppointAsAdmin: boolean;
60   collapsed: boolean;
61   viewSource: boolean;
62   showAdvanced: boolean;
63   showReportDialog: boolean;
64   reportReason: string;
65   my_vote: number;
66   score: number;
67   upvotes: number;
68   downvotes: number;
69   borderColor: string;
70   readLoading: boolean;
71   saveLoading: boolean;
72 }
73
74 interface CommentNodeProps {
75   node: CommentNodeI;
76   noBorder?: boolean;
77   noIndent?: boolean;
78   viewOnly?: boolean;
79   locked?: boolean;
80   markable?: boolean;
81   showContext?: boolean;
82   moderators: CommunityModeratorView[];
83   admins: PersonViewSafe[];
84   // TODO is this necessary, can't I get it from the node itself?
85   postCreatorId?: number;
86   showCommunity?: boolean;
87   enableDownvotes: boolean;
88 }
89
90 export class CommentNode extends Component<CommentNodeProps, CommentNodeState> {
91   private emptyState: CommentNodeState = {
92     showReply: false,
93     showEdit: false,
94     showRemoveDialog: false,
95     removeReason: null,
96     showBanDialog: false,
97     removeData: false,
98     banReason: null,
99     banExpires: null,
100     banType: BanType.Community,
101     collapsed: false,
102     viewSource: false,
103     showAdvanced: false,
104     showConfirmTransferSite: false,
105     showConfirmTransferCommunity: false,
106     showConfirmAppointAsMod: false,
107     showConfirmAppointAsAdmin: false,
108     showReportDialog: false,
109     reportReason: null,
110     my_vote: this.props.node.comment_view.my_vote,
111     score: this.props.node.comment_view.counts.score,
112     upvotes: this.props.node.comment_view.counts.upvotes,
113     downvotes: this.props.node.comment_view.counts.downvotes,
114     borderColor: this.props.node.depth
115       ? colorList[this.props.node.depth % colorList.length]
116       : colorList[0],
117     readLoading: false,
118     saveLoading: false,
119   };
120
121   constructor(props: any, context: any) {
122     super(props, context);
123
124     this.state = this.emptyState;
125     this.handleReplyCancel = this.handleReplyCancel.bind(this);
126     this.handleCommentUpvote = this.handleCommentUpvote.bind(this);
127     this.handleCommentDownvote = this.handleCommentDownvote.bind(this);
128   }
129
130   // TODO see if there's a better way to do this, and all willReceiveProps
131   componentWillReceiveProps(nextProps: CommentNodeProps) {
132     let cv = nextProps.node.comment_view;
133     this.state.my_vote = cv.my_vote;
134     this.state.upvotes = cv.counts.upvotes;
135     this.state.downvotes = cv.counts.downvotes;
136     this.state.score = cv.counts.score;
137     this.state.readLoading = false;
138     this.state.saveLoading = false;
139     this.setState(this.state);
140   }
141
142   render() {
143     let node = this.props.node;
144     let cv = this.props.node.comment_view;
145     return (
146       <div
147         className={`comment ${
148           cv.comment.parent_id && !this.props.noIndent ? "ml-1" : ""
149         }`}
150       >
151         <div
152           id={`comment-${cv.comment.id}`}
153           className={`details comment-node py-2 ${
154             !this.props.noBorder ? "border-top border-light" : ""
155           } ${this.isCommentNew ? "mark" : ""}`}
156           style={
157             !this.props.noIndent &&
158             cv.comment.parent_id &&
159             `border-left: 2px ${this.state.borderColor} solid !important`
160           }
161         >
162           <div
163             class={`${!this.props.noIndent && cv.comment.parent_id && "ml-2"}`}
164           >
165             <div class="d-flex flex-wrap align-items-center text-muted small">
166               <span class="mr-2">
167                 <PersonListing person={cv.creator} />
168               </span>
169
170               {this.isMod && (
171                 <div className="badge badge-light d-none d-sm-inline mr-2">
172                   {i18n.t("mod")}
173                 </div>
174               )}
175               {this.isAdmin && (
176                 <div className="badge badge-light d-none d-sm-inline mr-2">
177                   {i18n.t("admin")}
178                 </div>
179               )}
180               {this.isPostCreator && (
181                 <div className="badge badge-light d-none d-sm-inline mr-2">
182                   {i18n.t("creator")}
183                 </div>
184               )}
185               {(cv.creator_banned_from_community || cv.creator.banned) && (
186                 <div className="badge badge-danger mr-2">
187                   {i18n.t("banned")}
188                 </div>
189               )}
190               {this.props.showCommunity && (
191                 <>
192                   <span class="mx-1">{i18n.t("to")}</span>
193                   <CommunityLink community={cv.community} />
194                   <span class="mx-2">•</span>
195                   <Link className="mr-2" to={`/post/${cv.post.id}`}>
196                     {cv.post.name}
197                   </Link>
198                 </>
199               )}
200               <button
201                 class="btn btn-sm text-muted"
202                 onClick={linkEvent(this, this.handleCommentCollapse)}
203                 aria-label={this.expandText}
204                 data-tippy-content={this.expandText}
205               >
206                 {this.state.collapsed ? (
207                   <Icon icon="plus-square" classes="icon-inline" />
208                 ) : (
209                   <Icon icon="minus-square" classes="icon-inline" />
210                 )}
211               </button>
212               {this.linkBtn(true)}
213               {/* This is an expanding spacer for mobile */}
214               <div className="mr-lg-5 flex-grow-1 flex-lg-grow-0 unselectable pointer mx-2"></div>
215               {showScores() && (
216                 <>
217                   <a
218                     className={`unselectable pointer ${this.scoreColor}`}
219                     onClick={linkEvent(node, this.handleCommentUpvote)}
220                     data-tippy-content={this.pointsTippy}
221                   >
222                     <span
223                       class="mr-1 font-weight-bold"
224                       aria-label={i18n.t("number_of_points", {
225                         count: this.state.score,
226                         formattedCount: this.state.score,
227                       })}
228                     >
229                       {numToSI(this.state.score)}
230                     </span>
231                   </a>
232                   <span className="mr-1">•</span>
233                 </>
234               )}
235               <span>
236                 <MomentTime data={cv.comment} />
237               </span>
238             </div>
239             {/* end of user row */}
240             {this.state.showEdit && (
241               <CommentForm
242                 node={node}
243                 edit
244                 onReplyCancel={this.handleReplyCancel}
245                 disabled={this.props.locked}
246                 focus
247               />
248             )}
249             {!this.state.showEdit && !this.state.collapsed && (
250               <div>
251                 {this.state.viewSource ? (
252                   <pre>{this.commentUnlessRemoved}</pre>
253                 ) : (
254                   <div
255                     className="md-div"
256                     dangerouslySetInnerHTML={mdToHtml(
257                       this.commentUnlessRemoved
258                     )}
259                   />
260                 )}
261                 <div class="d-flex justify-content-between justify-content-lg-start flex-wrap text-muted font-weight-bold">
262                   {this.props.showContext && this.linkBtn()}
263                   {this.props.markable && (
264                     <button
265                       class="btn btn-link btn-animate text-muted"
266                       onClick={linkEvent(this, this.handleMarkRead)}
267                       data-tippy-content={
268                         this.commentOrMentionRead
269                           ? i18n.t("mark_as_unread")
270                           : i18n.t("mark_as_read")
271                       }
272                       aria-label={
273                         this.commentOrMentionRead
274                           ? i18n.t("mark_as_unread")
275                           : i18n.t("mark_as_read")
276                       }
277                     >
278                       {this.state.readLoading ? (
279                         this.loadingIcon
280                       ) : (
281                         <Icon
282                           icon="check"
283                           classes={`icon-inline ${
284                             this.commentOrMentionRead && "text-success"
285                           }`}
286                         />
287                       )}
288                     </button>
289                   )}
290                   {UserService.Instance.myUserInfo && !this.props.viewOnly && (
291                     <>
292                       <button
293                         className={`btn btn-link btn-animate ${
294                           this.state.my_vote == 1 ? "text-info" : "text-muted"
295                         }`}
296                         onClick={linkEvent(node, this.handleCommentUpvote)}
297                         data-tippy-content={i18n.t("upvote")}
298                         aria-label={i18n.t("upvote")}
299                       >
300                         <Icon icon="arrow-up1" classes="icon-inline" />
301                         {showScores() &&
302                           this.state.upvotes !== this.state.score && (
303                             <span class="ml-1">
304                               {numToSI(this.state.upvotes)}
305                             </span>
306                           )}
307                       </button>
308                       {this.props.enableDownvotes && (
309                         <button
310                           className={`btn btn-link btn-animate ${
311                             this.state.my_vote == -1
312                               ? "text-danger"
313                               : "text-muted"
314                           }`}
315                           onClick={linkEvent(node, this.handleCommentDownvote)}
316                           data-tippy-content={i18n.t("downvote")}
317                           aria-label={i18n.t("downvote")}
318                         >
319                           <Icon icon="arrow-down1" classes="icon-inline" />
320                           {showScores() &&
321                             this.state.upvotes !== this.state.score && (
322                               <span class="ml-1">
323                                 {numToSI(this.state.downvotes)}
324                               </span>
325                             )}
326                         </button>
327                       )}
328                       <button
329                         class="btn btn-link btn-animate text-muted"
330                         onClick={linkEvent(this, this.handleReplyClick)}
331                         data-tippy-content={i18n.t("reply")}
332                         aria-label={i18n.t("reply")}
333                       >
334                         <Icon icon="reply1" classes="icon-inline" />
335                       </button>
336                       {!this.state.showAdvanced ? (
337                         <button
338                           className="btn btn-link btn-animate text-muted"
339                           onClick={linkEvent(this, this.handleShowAdvanced)}
340                           data-tippy-content={i18n.t("more")}
341                           aria-label={i18n.t("more")}
342                         >
343                           <Icon icon="more-vertical" classes="icon-inline" />
344                         </button>
345                       ) : (
346                         <>
347                           {!this.myComment && (
348                             <>
349                               <button class="btn btn-link btn-animate">
350                                 <Link
351                                   className="text-muted"
352                                   to={`/create_private_message/recipient/${cv.creator.id}`}
353                                   title={i18n.t("message").toLowerCase()}
354                                 >
355                                   <Icon icon="mail" />
356                                 </Link>
357                               </button>
358                               <button
359                                 class="btn btn-link btn-animate text-muted"
360                                 onClick={linkEvent(
361                                   this,
362                                   this.handleShowReportDialog
363                                 )}
364                                 data-tippy-content={i18n.t(
365                                   "show_report_dialog"
366                                 )}
367                                 aria-label={i18n.t("show_report_dialog")}
368                               >
369                                 <Icon icon="flag" />
370                               </button>
371                               <button
372                                 class="btn btn-link btn-animate text-muted"
373                                 onClick={linkEvent(
374                                   this,
375                                   this.handleBlockUserClick
376                                 )}
377                                 data-tippy-content={i18n.t("block_user")}
378                                 aria-label={i18n.t("block_user")}
379                               >
380                                 <Icon icon="slash" />
381                               </button>
382                             </>
383                           )}
384                           <button
385                             class="btn btn-link btn-animate text-muted"
386                             onClick={linkEvent(
387                               this,
388                               this.handleSaveCommentClick
389                             )}
390                             data-tippy-content={
391                               cv.saved ? i18n.t("unsave") : i18n.t("save")
392                             }
393                             aria-label={
394                               cv.saved ? i18n.t("unsave") : i18n.t("save")
395                             }
396                           >
397                             {this.state.saveLoading ? (
398                               this.loadingIcon
399                             ) : (
400                               <Icon
401                                 icon="star"
402                                 classes={`icon-inline ${
403                                   cv.saved && "text-warning"
404                                 }`}
405                               />
406                             )}
407                           </button>
408                           <button
409                             className="btn btn-link btn-animate text-muted"
410                             onClick={linkEvent(this, this.handleViewSource)}
411                             data-tippy-content={i18n.t("view_source")}
412                             aria-label={i18n.t("view_source")}
413                           >
414                             <Icon
415                               icon="file-text"
416                               classes={`icon-inline ${
417                                 this.state.viewSource && "text-success"
418                               }`}
419                             />
420                           </button>
421                           {this.myComment && (
422                             <>
423                               <button
424                                 class="btn btn-link btn-animate text-muted"
425                                 onClick={linkEvent(this, this.handleEditClick)}
426                                 data-tippy-content={i18n.t("edit")}
427                                 aria-label={i18n.t("edit")}
428                               >
429                                 <Icon icon="edit" classes="icon-inline" />
430                               </button>
431                               <button
432                                 class="btn btn-link btn-animate text-muted"
433                                 onClick={linkEvent(
434                                   this,
435                                   this.handleDeleteClick
436                                 )}
437                                 data-tippy-content={
438                                   !cv.comment.deleted
439                                     ? i18n.t("delete")
440                                     : i18n.t("restore")
441                                 }
442                                 aria-label={
443                                   !cv.comment.deleted
444                                     ? i18n.t("delete")
445                                     : i18n.t("restore")
446                                 }
447                               >
448                                 <Icon
449                                   icon="trash"
450                                   classes={`icon-inline ${
451                                     cv.comment.deleted && "text-danger"
452                                   }`}
453                                 />
454                               </button>
455                             </>
456                           )}
457                           {/* Admins and mods can remove comments */}
458                           {(this.canMod || this.canAdmin) && (
459                             <>
460                               {!cv.comment.removed ? (
461                                 <button
462                                   class="btn btn-link btn-animate text-muted"
463                                   onClick={linkEvent(
464                                     this,
465                                     this.handleModRemoveShow
466                                   )}
467                                   aria-label={i18n.t("remove")}
468                                 >
469                                   {i18n.t("remove")}
470                                 </button>
471                               ) : (
472                                 <button
473                                   class="btn btn-link btn-animate text-muted"
474                                   onClick={linkEvent(
475                                     this,
476                                     this.handleModRemoveSubmit
477                                   )}
478                                   aria-label={i18n.t("restore")}
479                                 >
480                                   {i18n.t("restore")}
481                                 </button>
482                               )}
483                             </>
484                           )}
485                           {/* Mods can ban from community, and appoint as mods to community */}
486                           {this.canMod && (
487                             <>
488                               {!this.isMod &&
489                                 (!cv.creator_banned_from_community ? (
490                                   <button
491                                     class="btn btn-link btn-animate text-muted"
492                                     onClick={linkEvent(
493                                       this,
494                                       this.handleModBanFromCommunityShow
495                                     )}
496                                     aria-label={i18n.t("ban")}
497                                   >
498                                     {i18n.t("ban")}
499                                   </button>
500                                 ) : (
501                                   <button
502                                     class="btn btn-link btn-animate text-muted"
503                                     onClick={linkEvent(
504                                       this,
505                                       this.handleModBanFromCommunitySubmit
506                                     )}
507                                     aria-label={i18n.t("unban")}
508                                   >
509                                     {i18n.t("unban")}
510                                   </button>
511                                 ))}
512                               {!cv.creator_banned_from_community &&
513                                 (!this.state.showConfirmAppointAsMod ? (
514                                   <button
515                                     class="btn btn-link btn-animate text-muted"
516                                     onClick={linkEvent(
517                                       this,
518                                       this.handleShowConfirmAppointAsMod
519                                     )}
520                                     aria-label={
521                                       this.isMod
522                                         ? i18n.t("remove_as_mod")
523                                         : i18n.t("appoint_as_mod")
524                                     }
525                                   >
526                                     {this.isMod
527                                       ? i18n.t("remove_as_mod")
528                                       : i18n.t("appoint_as_mod")}
529                                   </button>
530                                 ) : (
531                                   <>
532                                     <button
533                                       class="btn btn-link btn-animate text-muted"
534                                       aria-label={i18n.t("are_you_sure")}
535                                     >
536                                       {i18n.t("are_you_sure")}
537                                     </button>
538                                     <button
539                                       class="btn btn-link btn-animate text-muted"
540                                       onClick={linkEvent(
541                                         this,
542                                         this.handleAddModToCommunity
543                                       )}
544                                       aria-label={i18n.t("yes")}
545                                     >
546                                       {i18n.t("yes")}
547                                     </button>
548                                     <button
549                                       class="btn btn-link btn-animate text-muted"
550                                       onClick={linkEvent(
551                                         this,
552                                         this.handleCancelConfirmAppointAsMod
553                                       )}
554                                       aria-label={i18n.t("no")}
555                                     >
556                                       {i18n.t("no")}
557                                     </button>
558                                   </>
559                                 ))}
560                             </>
561                           )}
562                           {/* Community creators and admins can transfer community to another mod */}
563                           {(this.amCommunityCreator || this.canAdmin) &&
564                             this.isMod &&
565                             cv.creator.local &&
566                             (!this.state.showConfirmTransferCommunity ? (
567                               <button
568                                 class="btn btn-link btn-animate text-muted"
569                                 onClick={linkEvent(
570                                   this,
571                                   this.handleShowConfirmTransferCommunity
572                                 )}
573                                 aria-label={i18n.t("transfer_community")}
574                               >
575                                 {i18n.t("transfer_community")}
576                               </button>
577                             ) : (
578                               <>
579                                 <button
580                                   class="btn btn-link btn-animate text-muted"
581                                   aria-label={i18n.t("are_you_sure")}
582                                 >
583                                   {i18n.t("are_you_sure")}
584                                 </button>
585                                 <button
586                                   class="btn btn-link btn-animate text-muted"
587                                   onClick={linkEvent(
588                                     this,
589                                     this.handleTransferCommunity
590                                   )}
591                                   aria-label={i18n.t("yes")}
592                                 >
593                                   {i18n.t("yes")}
594                                 </button>
595                                 <button
596                                   class="btn btn-link btn-animate text-muted"
597                                   onClick={linkEvent(
598                                     this,
599                                     this
600                                       .handleCancelShowConfirmTransferCommunity
601                                   )}
602                                   aria-label={i18n.t("no")}
603                                 >
604                                   {i18n.t("no")}
605                                 </button>
606                               </>
607                             ))}
608                           {/* Admins can ban from all, and appoint other admins */}
609                           {this.canAdmin && (
610                             <>
611                               {!this.isAdmin &&
612                                 (!cv.creator.banned ? (
613                                   <button
614                                     class="btn btn-link btn-animate text-muted"
615                                     onClick={linkEvent(
616                                       this,
617                                       this.handleModBanShow
618                                     )}
619                                     aria-label={i18n.t("ban_from_site")}
620                                   >
621                                     {i18n.t("ban_from_site")}
622                                   </button>
623                                 ) : (
624                                   <button
625                                     class="btn btn-link btn-animate text-muted"
626                                     onClick={linkEvent(
627                                       this,
628                                       this.handleModBanSubmit
629                                     )}
630                                     aria-label={i18n.t("unban_from_site")}
631                                   >
632                                     {i18n.t("unban_from_site")}
633                                   </button>
634                                 ))}
635                               {!cv.creator.banned &&
636                                 cv.creator.local &&
637                                 (!this.state.showConfirmAppointAsAdmin ? (
638                                   <button
639                                     class="btn btn-link btn-animate text-muted"
640                                     onClick={linkEvent(
641                                       this,
642                                       this.handleShowConfirmAppointAsAdmin
643                                     )}
644                                     aria-label={
645                                       this.isAdmin
646                                         ? i18n.t("remove_as_admin")
647                                         : i18n.t("appoint_as_admin")
648                                     }
649                                   >
650                                     {this.isAdmin
651                                       ? i18n.t("remove_as_admin")
652                                       : i18n.t("appoint_as_admin")}
653                                   </button>
654                                 ) : (
655                                   <>
656                                     <button class="btn btn-link btn-animate text-muted">
657                                       {i18n.t("are_you_sure")}
658                                     </button>
659                                     <button
660                                       class="btn btn-link btn-animate text-muted"
661                                       onClick={linkEvent(
662                                         this,
663                                         this.handleAddAdmin
664                                       )}
665                                       aria-label={i18n.t("yes")}
666                                     >
667                                       {i18n.t("yes")}
668                                     </button>
669                                     <button
670                                       class="btn btn-link btn-animate text-muted"
671                                       onClick={linkEvent(
672                                         this,
673                                         this.handleCancelConfirmAppointAsAdmin
674                                       )}
675                                       aria-label={i18n.t("no")}
676                                     >
677                                       {i18n.t("no")}
678                                     </button>
679                                   </>
680                                 ))}
681                             </>
682                           )}
683                           {/* Site Creator can transfer to another admin */}
684                           {this.amSiteCreator &&
685                             this.isAdmin &&
686                             cv.creator.local &&
687                             (!this.state.showConfirmTransferSite ? (
688                               <button
689                                 class="btn btn-link btn-animate text-muted"
690                                 onClick={linkEvent(
691                                   this,
692                                   this.handleShowConfirmTransferSite
693                                 )}
694                                 aria-label={i18n.t("transfer_site")}
695                               >
696                                 {i18n.t("transfer_site")}
697                               </button>
698                             ) : (
699                               <>
700                                 <button
701                                   class="btn btn-link btn-animate text-muted"
702                                   aria-label={i18n.t("are_you_sure")}
703                                 >
704                                   {i18n.t("are_you_sure")}
705                                 </button>
706                                 <button
707                                   class="btn btn-link btn-animate text-muted"
708                                   onClick={linkEvent(
709                                     this,
710                                     this.handleTransferSite
711                                   )}
712                                   aria-label={i18n.t("yes")}
713                                 >
714                                   {i18n.t("yes")}
715                                 </button>
716                                 <button
717                                   class="btn btn-link btn-animate text-muted"
718                                   onClick={linkEvent(
719                                     this,
720                                     this.handleCancelShowConfirmTransferSite
721                                   )}
722                                   aria-label={i18n.t("no")}
723                                 >
724                                   {i18n.t("no")}
725                                 </button>
726                               </>
727                             ))}
728                         </>
729                       )}
730                     </>
731                   )}
732                 </div>
733                 {/* end of button group */}
734               </div>
735             )}
736           </div>
737         </div>
738         {/* end of details */}
739         {this.state.showRemoveDialog && (
740           <form
741             class="form-inline"
742             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
743           >
744             <label
745               class="sr-only"
746               htmlFor={`mod-remove-reason-${cv.comment.id}`}
747             >
748               {i18n.t("reason")}
749             </label>
750             <input
751               type="text"
752               id={`mod-remove-reason-${cv.comment.id}`}
753               class="form-control mr-2"
754               placeholder={i18n.t("reason")}
755               value={this.state.removeReason}
756               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
757             />
758             <button
759               type="submit"
760               class="btn btn-secondary"
761               aria-label={i18n.t("remove_comment")}
762             >
763               {i18n.t("remove_comment")}
764             </button>
765           </form>
766         )}
767         {this.state.showReportDialog && (
768           <form
769             class="form-inline"
770             onSubmit={linkEvent(this, this.handleReportSubmit)}
771           >
772             <label class="sr-only" htmlFor={`report-reason-${cv.comment.id}`}>
773               {i18n.t("reason")}
774             </label>
775             <input
776               type="text"
777               required
778               id={`report-reason-${cv.comment.id}`}
779               class="form-control mr-2"
780               placeholder={i18n.t("reason")}
781               value={this.state.reportReason}
782               onInput={linkEvent(this, this.handleReportReasonChange)}
783             />
784             <button
785               type="submit"
786               class="btn btn-secondary"
787               aria-label={i18n.t("create_report")}
788             >
789               {i18n.t("create_report")}
790             </button>
791           </form>
792         )}
793         {this.state.showBanDialog && (
794           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
795             <div class="form-group row">
796               <label
797                 class="col-form-label"
798                 htmlFor={`mod-ban-reason-${cv.comment.id}`}
799               >
800                 {i18n.t("reason")}
801               </label>
802               <input
803                 type="text"
804                 id={`mod-ban-reason-${cv.comment.id}`}
805                 class="form-control mr-2"
806                 placeholder={i18n.t("reason")}
807                 value={this.state.banReason}
808                 onInput={linkEvent(this, this.handleModBanReasonChange)}
809               />
810               <div class="form-group">
811                 <div class="form-check">
812                   <input
813                     class="form-check-input"
814                     id="mod-ban-remove-data"
815                     type="checkbox"
816                     checked={this.state.removeData}
817                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
818                   />
819                   <label
820                     class="form-check-label"
821                     htmlFor="mod-ban-remove-data"
822                     title={i18n.t("remove_content_more")}
823                   >
824                     {i18n.t("remove_content")}
825                   </label>
826                 </div>
827               </div>
828             </div>
829             {/* TODO hold off on expires until later */}
830             {/* <div class="form-group row"> */}
831             {/*   <label class="col-form-label">Expires</label> */}
832             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
833             {/* </div> */}
834             <div class="form-group row">
835               <button
836                 type="submit"
837                 class="btn btn-secondary"
838                 aria-label={i18n.t("ban")}
839               >
840                 {i18n.t("ban")} {cv.creator.name}
841               </button>
842             </div>
843           </form>
844         )}
845         {this.state.showReply && (
846           <CommentForm
847             node={node}
848             onReplyCancel={this.handleReplyCancel}
849             disabled={this.props.locked}
850             focus
851           />
852         )}
853         {node.children && !this.state.collapsed && (
854           <CommentNodes
855             nodes={node.children}
856             locked={this.props.locked}
857             moderators={this.props.moderators}
858             admins={this.props.admins}
859             postCreatorId={this.props.postCreatorId}
860             enableDownvotes={this.props.enableDownvotes}
861           />
862         )}
863         {/* A collapsed clearfix */}
864         {this.state.collapsed && <div class="row col-12"></div>}
865       </div>
866     );
867   }
868
869   get commentOrMentionRead() {
870     let cv = this.props.node.comment_view;
871     return this.isPersonMentionType(cv)
872       ? cv.person_mention.read
873       : cv.comment.read;
874   }
875
876   linkBtn(small = false) {
877     let cv = this.props.node.comment_view;
878     return (
879       <Link
880         className={`btn ${small && "btn-sm"} btn-link btn-animate text-muted`}
881         to={`/post/${cv.post.id}/comment/${cv.comment.id}`}
882         title={this.props.showContext ? i18n.t("show_context") : i18n.t("link")}
883       >
884         <Icon icon="link" classes="icon-inline" />
885       </Link>
886     );
887   }
888
889   get loadingIcon() {
890     return <Spinner />;
891   }
892
893   get myComment(): boolean {
894     return (
895       this.props.node.comment_view.creator.id ==
896       UserService.Instance.myUserInfo?.local_user_view.person.id
897     );
898   }
899
900   get isMod(): boolean {
901     return (
902       this.props.moderators &&
903       isMod(
904         this.props.moderators.map(m => m.moderator.id),
905         this.props.node.comment_view.creator.id
906       )
907     );
908   }
909
910   get isAdmin(): boolean {
911     return (
912       this.props.admins &&
913       isMod(
914         this.props.admins.map(a => a.person.id),
915         this.props.node.comment_view.creator.id
916       )
917     );
918   }
919
920   get isPostCreator(): boolean {
921     return this.props.node.comment_view.creator.id == this.props.postCreatorId;
922   }
923
924   get canMod(): boolean {
925     if (this.props.admins && this.props.moderators) {
926       let adminsThenMods = this.props.admins
927         .map(a => a.person.id)
928         .concat(this.props.moderators.map(m => m.moderator.id));
929
930       return canMod(
931         UserService.Instance.myUserInfo,
932         adminsThenMods,
933         this.props.node.comment_view.creator.id
934       );
935     } else {
936       return false;
937     }
938   }
939
940   get canAdmin(): boolean {
941     return (
942       this.props.admins &&
943       canMod(
944         UserService.Instance.myUserInfo,
945         this.props.admins.map(a => a.person.id),
946         this.props.node.comment_view.creator.id
947       )
948     );
949   }
950
951   get amCommunityCreator(): boolean {
952     return (
953       this.props.moderators &&
954       UserService.Instance.myUserInfo &&
955       this.props.node.comment_view.creator.id !=
956         UserService.Instance.myUserInfo.local_user_view.person.id &&
957       UserService.Instance.myUserInfo.local_user_view.person.id ==
958         this.props.moderators[0].moderator.id
959     );
960   }
961
962   get amSiteCreator(): boolean {
963     return (
964       this.props.admins &&
965       UserService.Instance.myUserInfo &&
966       this.props.node.comment_view.creator.id !=
967         UserService.Instance.myUserInfo.local_user_view.person.id &&
968       UserService.Instance.myUserInfo.local_user_view.person.id ==
969         this.props.admins[0].person.id
970     );
971   }
972
973   get commentUnlessRemoved(): string {
974     let comment = this.props.node.comment_view.comment;
975     return comment.removed
976       ? `*${i18n.t("removed")}*`
977       : comment.deleted
978       ? `*${i18n.t("deleted")}*`
979       : comment.content;
980   }
981
982   handleReplyClick(i: CommentNode) {
983     i.state.showReply = true;
984     i.setState(i.state);
985   }
986
987   handleEditClick(i: CommentNode) {
988     i.state.showEdit = true;
989     i.setState(i.state);
990   }
991
992   handleBlockUserClick(i: CommentNode) {
993     let blockUserForm: BlockPerson = {
994       person_id: i.props.node.comment_view.creator.id,
995       block: true,
996       auth: authField(),
997     };
998     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
999   }
1000
1001   handleDeleteClick(i: CommentNode) {
1002     let comment = i.props.node.comment_view.comment;
1003     let deleteForm: DeleteComment = {
1004       comment_id: comment.id,
1005       deleted: !comment.deleted,
1006       auth: authField(),
1007     };
1008     WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
1009   }
1010
1011   handleSaveCommentClick(i: CommentNode) {
1012     let cv = i.props.node.comment_view;
1013     let save = cv.saved == undefined ? true : !cv.saved;
1014     let form: SaveComment = {
1015       comment_id: cv.comment.id,
1016       save,
1017       auth: authField(),
1018     };
1019
1020     WebSocketService.Instance.send(wsClient.saveComment(form));
1021
1022     i.state.saveLoading = true;
1023     i.setState(this.state);
1024   }
1025
1026   handleReplyCancel() {
1027     this.state.showReply = false;
1028     this.state.showEdit = false;
1029     this.setState(this.state);
1030   }
1031
1032   handleCommentUpvote(i: CommentNodeI, event: any) {
1033     event.preventDefault();
1034     let new_vote = this.state.my_vote == 1 ? 0 : 1;
1035
1036     if (this.state.my_vote == 1) {
1037       this.state.score--;
1038       this.state.upvotes--;
1039     } else if (this.state.my_vote == -1) {
1040       this.state.downvotes--;
1041       this.state.upvotes++;
1042       this.state.score += 2;
1043     } else {
1044       this.state.upvotes++;
1045       this.state.score++;
1046     }
1047
1048     this.state.my_vote = new_vote;
1049
1050     let form: CreateCommentLike = {
1051       comment_id: i.comment_view.comment.id,
1052       score: this.state.my_vote,
1053       auth: authField(),
1054     };
1055
1056     WebSocketService.Instance.send(wsClient.likeComment(form));
1057     this.setState(this.state);
1058     setupTippy();
1059   }
1060
1061   handleCommentDownvote(i: CommentNodeI, event: any) {
1062     event.preventDefault();
1063     let new_vote = this.state.my_vote == -1 ? 0 : -1;
1064
1065     if (this.state.my_vote == 1) {
1066       this.state.score -= 2;
1067       this.state.upvotes--;
1068       this.state.downvotes++;
1069     } else if (this.state.my_vote == -1) {
1070       this.state.downvotes--;
1071       this.state.score++;
1072     } else {
1073       this.state.downvotes++;
1074       this.state.score--;
1075     }
1076
1077     this.state.my_vote = new_vote;
1078
1079     let form: CreateCommentLike = {
1080       comment_id: i.comment_view.comment.id,
1081       score: this.state.my_vote,
1082       auth: authField(),
1083     };
1084
1085     WebSocketService.Instance.send(wsClient.likeComment(form));
1086     this.setState(this.state);
1087     setupTippy();
1088   }
1089
1090   handleShowReportDialog(i: CommentNode) {
1091     i.state.showReportDialog = !i.state.showReportDialog;
1092     i.setState(i.state);
1093   }
1094
1095   handleReportReasonChange(i: CommentNode, event: any) {
1096     i.state.reportReason = event.target.value;
1097     i.setState(i.state);
1098   }
1099
1100   handleReportSubmit(i: CommentNode) {
1101     let comment = i.props.node.comment_view.comment;
1102     let form: CreateCommentReport = {
1103       comment_id: comment.id,
1104       reason: i.state.reportReason,
1105       auth: authField(),
1106     };
1107     WebSocketService.Instance.send(wsClient.createCommentReport(form));
1108
1109     i.state.showReportDialog = false;
1110     i.setState(i.state);
1111   }
1112
1113   handleModRemoveShow(i: CommentNode) {
1114     i.state.showRemoveDialog = true;
1115     i.setState(i.state);
1116   }
1117
1118   handleModRemoveReasonChange(i: CommentNode, event: any) {
1119     i.state.removeReason = event.target.value;
1120     i.setState(i.state);
1121   }
1122
1123   handleModRemoveDataChange(i: CommentNode, event: any) {
1124     i.state.removeData = event.target.checked;
1125     i.setState(i.state);
1126   }
1127
1128   handleModRemoveSubmit(i: CommentNode) {
1129     let comment = i.props.node.comment_view.comment;
1130     let form: RemoveComment = {
1131       comment_id: comment.id,
1132       removed: !comment.removed,
1133       reason: i.state.removeReason,
1134       auth: authField(),
1135     };
1136     WebSocketService.Instance.send(wsClient.removeComment(form));
1137
1138     i.state.showRemoveDialog = false;
1139     i.setState(i.state);
1140   }
1141
1142   isPersonMentionType(
1143     item: CommentView | PersonMentionView
1144   ): item is PersonMentionView {
1145     return (item as PersonMentionView).person_mention?.id !== undefined;
1146   }
1147
1148   handleMarkRead(i: CommentNode) {
1149     if (i.isPersonMentionType(i.props.node.comment_view)) {
1150       let form: MarkPersonMentionAsRead = {
1151         person_mention_id: i.props.node.comment_view.person_mention.id,
1152         read: !i.props.node.comment_view.person_mention.read,
1153         auth: authField(),
1154       };
1155       WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
1156     } else {
1157       let form: MarkCommentAsRead = {
1158         comment_id: i.props.node.comment_view.comment.id,
1159         read: !i.props.node.comment_view.comment.read,
1160         auth: authField(),
1161       };
1162       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
1163     }
1164
1165     i.state.readLoading = true;
1166     i.setState(this.state);
1167   }
1168
1169   handleModBanFromCommunityShow(i: CommentNode) {
1170     i.state.showBanDialog = !i.state.showBanDialog;
1171     i.state.banType = BanType.Community;
1172     i.setState(i.state);
1173   }
1174
1175   handleModBanShow(i: CommentNode) {
1176     i.state.showBanDialog = !i.state.showBanDialog;
1177     i.state.banType = BanType.Site;
1178     i.setState(i.state);
1179   }
1180
1181   handleModBanReasonChange(i: CommentNode, event: any) {
1182     i.state.banReason = event.target.value;
1183     i.setState(i.state);
1184   }
1185
1186   handleModBanExpiresChange(i: CommentNode, event: any) {
1187     i.state.banExpires = event.target.value;
1188     i.setState(i.state);
1189   }
1190
1191   handleModBanFromCommunitySubmit(i: CommentNode) {
1192     i.state.banType = BanType.Community;
1193     i.setState(i.state);
1194     i.handleModBanBothSubmit(i);
1195   }
1196
1197   handleModBanSubmit(i: CommentNode) {
1198     i.state.banType = BanType.Site;
1199     i.setState(i.state);
1200     i.handleModBanBothSubmit(i);
1201   }
1202
1203   handleModBanBothSubmit(i: CommentNode) {
1204     let cv = i.props.node.comment_view;
1205
1206     if (i.state.banType == BanType.Community) {
1207       // If its an unban, restore all their data
1208       let ban = !cv.creator_banned_from_community;
1209       if (ban == false) {
1210         i.state.removeData = false;
1211       }
1212       let form: BanFromCommunity = {
1213         person_id: cv.creator.id,
1214         community_id: cv.community.id,
1215         ban,
1216         remove_data: i.state.removeData,
1217         reason: i.state.banReason,
1218         expires: getUnixTime(i.state.banExpires),
1219         auth: authField(),
1220       };
1221       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1222     } else {
1223       // If its an unban, restore all their data
1224       let ban = !cv.creator.banned;
1225       if (ban == false) {
1226         i.state.removeData = false;
1227       }
1228       let form: BanPerson = {
1229         person_id: cv.creator.id,
1230         ban,
1231         remove_data: i.state.removeData,
1232         reason: i.state.banReason,
1233         expires: getUnixTime(i.state.banExpires),
1234         auth: authField(),
1235       };
1236       WebSocketService.Instance.send(wsClient.banPerson(form));
1237     }
1238
1239     i.state.showBanDialog = false;
1240     i.setState(i.state);
1241   }
1242
1243   handleShowConfirmAppointAsMod(i: CommentNode) {
1244     i.state.showConfirmAppointAsMod = true;
1245     i.setState(i.state);
1246   }
1247
1248   handleCancelConfirmAppointAsMod(i: CommentNode) {
1249     i.state.showConfirmAppointAsMod = false;
1250     i.setState(i.state);
1251   }
1252
1253   handleAddModToCommunity(i: CommentNode) {
1254     let cv = i.props.node.comment_view;
1255     let form: AddModToCommunity = {
1256       person_id: cv.creator.id,
1257       community_id: cv.community.id,
1258       added: !i.isMod,
1259       auth: authField(),
1260     };
1261     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1262     i.state.showConfirmAppointAsMod = false;
1263     i.setState(i.state);
1264   }
1265
1266   handleShowConfirmAppointAsAdmin(i: CommentNode) {
1267     i.state.showConfirmAppointAsAdmin = true;
1268     i.setState(i.state);
1269   }
1270
1271   handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1272     i.state.showConfirmAppointAsAdmin = false;
1273     i.setState(i.state);
1274   }
1275
1276   handleAddAdmin(i: CommentNode) {
1277     let form: AddAdmin = {
1278       person_id: i.props.node.comment_view.creator.id,
1279       added: !i.isAdmin,
1280       auth: authField(),
1281     };
1282     WebSocketService.Instance.send(wsClient.addAdmin(form));
1283     i.state.showConfirmAppointAsAdmin = false;
1284     i.setState(i.state);
1285   }
1286
1287   handleShowConfirmTransferCommunity(i: CommentNode) {
1288     i.state.showConfirmTransferCommunity = true;
1289     i.setState(i.state);
1290   }
1291
1292   handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1293     i.state.showConfirmTransferCommunity = false;
1294     i.setState(i.state);
1295   }
1296
1297   handleTransferCommunity(i: CommentNode) {
1298     let cv = i.props.node.comment_view;
1299     let form: TransferCommunity = {
1300       community_id: cv.community.id,
1301       person_id: cv.creator.id,
1302       auth: authField(),
1303     };
1304     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1305     i.state.showConfirmTransferCommunity = false;
1306     i.setState(i.state);
1307   }
1308
1309   handleShowConfirmTransferSite(i: CommentNode) {
1310     i.state.showConfirmTransferSite = true;
1311     i.setState(i.state);
1312   }
1313
1314   handleCancelShowConfirmTransferSite(i: CommentNode) {
1315     i.state.showConfirmTransferSite = false;
1316     i.setState(i.state);
1317   }
1318
1319   handleTransferSite(i: CommentNode) {
1320     let form: TransferSite = {
1321       person_id: i.props.node.comment_view.creator.id,
1322       auth: authField(),
1323     };
1324     WebSocketService.Instance.send(wsClient.transferSite(form));
1325     i.state.showConfirmTransferSite = false;
1326     i.setState(i.state);
1327   }
1328
1329   get isCommentNew(): boolean {
1330     let now = moment.utc().subtract(10, "minutes");
1331     let then = moment.utc(this.props.node.comment_view.comment.published);
1332     return now.isBefore(then);
1333   }
1334
1335   handleCommentCollapse(i: CommentNode) {
1336     i.state.collapsed = !i.state.collapsed;
1337     i.setState(i.state);
1338     setupTippy();
1339   }
1340
1341   handleViewSource(i: CommentNode) {
1342     i.state.viewSource = !i.state.viewSource;
1343     i.setState(i.state);
1344   }
1345
1346   handleShowAdvanced(i: CommentNode) {
1347     i.state.showAdvanced = !i.state.showAdvanced;
1348     i.setState(i.state);
1349     setupTippy();
1350   }
1351
1352   get scoreColor() {
1353     if (this.state.my_vote == 1) {
1354       return "text-info";
1355     } else if (this.state.my_vote == -1) {
1356       return "text-danger";
1357     } else {
1358       return "text-muted";
1359     }
1360   }
1361
1362   get pointsTippy(): string {
1363     let points = i18n.t("number_of_points", {
1364       count: this.state.score,
1365       formattedCount: this.state.score,
1366     });
1367
1368     let upvotes = i18n.t("number_of_upvotes", {
1369       count: this.state.upvotes,
1370       formattedCount: this.state.upvotes,
1371     });
1372
1373     let downvotes = i18n.t("number_of_downvotes", {
1374       count: this.state.downvotes,
1375       formattedCount: this.state.downvotes,
1376     });
1377
1378     return `${points} • ${upvotes} • ${downvotes}`;
1379   }
1380
1381   get expandText(): string {
1382     return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
1383   }
1384 }