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