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