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