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