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