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