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