]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Don't allow transfer site. (#551)
[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 } from "lemmy-js-client";
22 import moment from "moment";
23 import { i18n } from "../../i18next";
24 import { BanType, CommentNode as CommentNodeI } from "../../interfaces";
25 import { UserService, WebSocketService } from "../../services";
26 import {
27   authField,
28   canMod,
29   colorList,
30   futureDaysToUnixTime,
31   isBanned,
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   banExpireDays: number;
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     banExpireDays: 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 || isBanned(cv.creator)) && (
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                                 (!isBanned(cv.creator) ? (
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                               {!isBanned(cv.creator) &&
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                         </>
689                       )}
690                     </>
691                   )}
692                 </div>
693                 {/* end of button group */}
694               </div>
695             )}
696           </div>
697         </div>
698         {/* end of details */}
699         {this.state.showRemoveDialog && (
700           <form
701             class="form-inline"
702             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
703           >
704             <label
705               class="sr-only"
706               htmlFor={`mod-remove-reason-${cv.comment.id}`}
707             >
708               {i18n.t("reason")}
709             </label>
710             <input
711               type="text"
712               id={`mod-remove-reason-${cv.comment.id}`}
713               class="form-control mr-2"
714               placeholder={i18n.t("reason")}
715               value={this.state.removeReason}
716               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
717             />
718             <button
719               type="submit"
720               class="btn btn-secondary"
721               aria-label={i18n.t("remove_comment")}
722             >
723               {i18n.t("remove_comment")}
724             </button>
725           </form>
726         )}
727         {this.state.showReportDialog && (
728           <form
729             class="form-inline"
730             onSubmit={linkEvent(this, this.handleReportSubmit)}
731           >
732             <label class="sr-only" htmlFor={`report-reason-${cv.comment.id}`}>
733               {i18n.t("reason")}
734             </label>
735             <input
736               type="text"
737               required
738               id={`report-reason-${cv.comment.id}`}
739               class="form-control mr-2"
740               placeholder={i18n.t("reason")}
741               value={this.state.reportReason}
742               onInput={linkEvent(this, this.handleReportReasonChange)}
743             />
744             <button
745               type="submit"
746               class="btn btn-secondary"
747               aria-label={i18n.t("create_report")}
748             >
749               {i18n.t("create_report")}
750             </button>
751           </form>
752         )}
753         {this.state.showBanDialog && (
754           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
755             <div class="form-group row col-12">
756               <label
757                 class="col-form-label"
758                 htmlFor={`mod-ban-reason-${cv.comment.id}`}
759               >
760                 {i18n.t("reason")}
761               </label>
762               <input
763                 type="text"
764                 id={`mod-ban-reason-${cv.comment.id}`}
765                 class="form-control mr-2"
766                 placeholder={i18n.t("reason")}
767                 value={this.state.banReason}
768                 onInput={linkEvent(this, this.handleModBanReasonChange)}
769               />
770               <label
771                 class="col-form-label"
772                 htmlFor={`mod-ban-expires-${cv.comment.id}`}
773               >
774                 {i18n.t("expires")}
775               </label>
776               <input
777                 type="number"
778                 id={`mod-ban-expires-${cv.comment.id}`}
779                 class="form-control mr-2"
780                 placeholder={i18n.t("number_of_days")}
781                 value={this.state.banExpireDays}
782                 onInput={linkEvent(this, this.handleModBanExpireDaysChange)}
783               />
784               <div class="form-group">
785                 <div class="form-check">
786                   <input
787                     class="form-check-input"
788                     id="mod-ban-remove-data"
789                     type="checkbox"
790                     checked={this.state.removeData}
791                     onChange={linkEvent(this, this.handleModRemoveDataChange)}
792                   />
793                   <label
794                     class="form-check-label"
795                     htmlFor="mod-ban-remove-data"
796                     title={i18n.t("remove_content_more")}
797                   >
798                     {i18n.t("remove_content")}
799                   </label>
800                 </div>
801               </div>
802             </div>
803             {/* TODO hold off on expires until later */}
804             {/* <div class="form-group row"> */}
805             {/*   <label class="col-form-label">Expires</label> */}
806             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
807             {/* </div> */}
808             <div class="form-group row">
809               <button
810                 type="submit"
811                 class="btn btn-secondary"
812                 aria-label={i18n.t("ban")}
813               >
814                 {i18n.t("ban")} {cv.creator.name}
815               </button>
816             </div>
817           </form>
818         )}
819         {this.state.showReply && (
820           <CommentForm
821             node={node}
822             onReplyCancel={this.handleReplyCancel}
823             disabled={this.props.locked}
824             focus
825           />
826         )}
827         {node.children && !this.state.collapsed && (
828           <CommentNodes
829             nodes={node.children}
830             locked={this.props.locked}
831             moderators={this.props.moderators}
832             admins={this.props.admins}
833             postCreatorId={this.props.postCreatorId}
834             enableDownvotes={this.props.enableDownvotes}
835           />
836         )}
837         {/* A collapsed clearfix */}
838         {this.state.collapsed && <div class="row col-12"></div>}
839       </div>
840     );
841   }
842
843   get commentOrMentionRead() {
844     let cv = this.props.node.comment_view;
845     return this.isPersonMentionType(cv)
846       ? cv.person_mention.read
847       : cv.comment.read;
848   }
849
850   linkBtn(small = false) {
851     let cv = this.props.node.comment_view;
852     return (
853       <Link
854         className={`btn ${small && "btn-sm"} btn-link btn-animate text-muted`}
855         to={`/post/${cv.post.id}/comment/${cv.comment.id}`}
856         title={this.props.showContext ? i18n.t("show_context") : i18n.t("link")}
857       >
858         <Icon icon="link" classes="icon-inline" />
859       </Link>
860     );
861   }
862
863   get loadingIcon() {
864     return <Spinner />;
865   }
866
867   get myComment(): boolean {
868     return (
869       this.props.node.comment_view.creator.id ==
870       UserService.Instance.myUserInfo?.local_user_view.person.id
871     );
872   }
873
874   get isMod(): boolean {
875     return (
876       this.props.moderators &&
877       isMod(
878         this.props.moderators.map(m => m.moderator.id),
879         this.props.node.comment_view.creator.id
880       )
881     );
882   }
883
884   get isAdmin(): boolean {
885     return (
886       this.props.admins &&
887       isMod(
888         this.props.admins.map(a => a.person.id),
889         this.props.node.comment_view.creator.id
890       )
891     );
892   }
893
894   get isPostCreator(): boolean {
895     return this.props.node.comment_view.creator.id == this.props.postCreatorId;
896   }
897
898   get canMod(): boolean {
899     if (this.props.admins && this.props.moderators) {
900       let adminsThenMods = this.props.admins
901         .map(a => a.person.id)
902         .concat(this.props.moderators.map(m => m.moderator.id));
903
904       return canMod(
905         UserService.Instance.myUserInfo,
906         adminsThenMods,
907         this.props.node.comment_view.creator.id
908       );
909     } else {
910       return false;
911     }
912   }
913
914   get canAdmin(): boolean {
915     return (
916       this.props.admins &&
917       canMod(
918         UserService.Instance.myUserInfo,
919         this.props.admins.map(a => a.person.id),
920         this.props.node.comment_view.creator.id
921       )
922     );
923   }
924
925   get amCommunityCreator(): boolean {
926     return (
927       this.props.moderators &&
928       UserService.Instance.myUserInfo &&
929       this.props.node.comment_view.creator.id !=
930         UserService.Instance.myUserInfo.local_user_view.person.id &&
931       UserService.Instance.myUserInfo.local_user_view.person.id ==
932         this.props.moderators[0].moderator.id
933     );
934   }
935
936   get amSiteCreator(): boolean {
937     return (
938       this.props.admins &&
939       UserService.Instance.myUserInfo &&
940       this.props.node.comment_view.creator.id !=
941         UserService.Instance.myUserInfo.local_user_view.person.id &&
942       UserService.Instance.myUserInfo.local_user_view.person.id ==
943         this.props.admins[0].person.id
944     );
945   }
946
947   get commentUnlessRemoved(): string {
948     let comment = this.props.node.comment_view.comment;
949     return comment.removed
950       ? `*${i18n.t("removed")}*`
951       : comment.deleted
952       ? `*${i18n.t("deleted")}*`
953       : comment.content;
954   }
955
956   handleReplyClick(i: CommentNode) {
957     i.state.showReply = true;
958     i.setState(i.state);
959   }
960
961   handleEditClick(i: CommentNode) {
962     i.state.showEdit = true;
963     i.setState(i.state);
964   }
965
966   handleBlockUserClick(i: CommentNode) {
967     let blockUserForm: BlockPerson = {
968       person_id: i.props.node.comment_view.creator.id,
969       block: true,
970       auth: authField(),
971     };
972     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
973   }
974
975   handleDeleteClick(i: CommentNode) {
976     let comment = i.props.node.comment_view.comment;
977     let deleteForm: DeleteComment = {
978       comment_id: comment.id,
979       deleted: !comment.deleted,
980       auth: authField(),
981     };
982     WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
983   }
984
985   handleSaveCommentClick(i: CommentNode) {
986     let cv = i.props.node.comment_view;
987     let save = cv.saved == undefined ? true : !cv.saved;
988     let form: SaveComment = {
989       comment_id: cv.comment.id,
990       save,
991       auth: authField(),
992     };
993
994     WebSocketService.Instance.send(wsClient.saveComment(form));
995
996     i.state.saveLoading = true;
997     i.setState(this.state);
998   }
999
1000   handleReplyCancel() {
1001     this.state.showReply = false;
1002     this.state.showEdit = false;
1003     this.setState(this.state);
1004   }
1005
1006   handleCommentUpvote(i: CommentNodeI, event: any) {
1007     event.preventDefault();
1008     let new_vote = this.state.my_vote == 1 ? 0 : 1;
1009
1010     if (this.state.my_vote == 1) {
1011       this.state.score--;
1012       this.state.upvotes--;
1013     } else if (this.state.my_vote == -1) {
1014       this.state.downvotes--;
1015       this.state.upvotes++;
1016       this.state.score += 2;
1017     } else {
1018       this.state.upvotes++;
1019       this.state.score++;
1020     }
1021
1022     this.state.my_vote = new_vote;
1023
1024     let form: CreateCommentLike = {
1025       comment_id: i.comment_view.comment.id,
1026       score: this.state.my_vote,
1027       auth: authField(),
1028     };
1029
1030     WebSocketService.Instance.send(wsClient.likeComment(form));
1031     this.setState(this.state);
1032     setupTippy();
1033   }
1034
1035   handleCommentDownvote(i: CommentNodeI, event: any) {
1036     event.preventDefault();
1037     let new_vote = this.state.my_vote == -1 ? 0 : -1;
1038
1039     if (this.state.my_vote == 1) {
1040       this.state.score -= 2;
1041       this.state.upvotes--;
1042       this.state.downvotes++;
1043     } else if (this.state.my_vote == -1) {
1044       this.state.downvotes--;
1045       this.state.score++;
1046     } else {
1047       this.state.downvotes++;
1048       this.state.score--;
1049     }
1050
1051     this.state.my_vote = new_vote;
1052
1053     let form: CreateCommentLike = {
1054       comment_id: i.comment_view.comment.id,
1055       score: this.state.my_vote,
1056       auth: authField(),
1057     };
1058
1059     WebSocketService.Instance.send(wsClient.likeComment(form));
1060     this.setState(this.state);
1061     setupTippy();
1062   }
1063
1064   handleShowReportDialog(i: CommentNode) {
1065     i.state.showReportDialog = !i.state.showReportDialog;
1066     i.setState(i.state);
1067   }
1068
1069   handleReportReasonChange(i: CommentNode, event: any) {
1070     i.state.reportReason = event.target.value;
1071     i.setState(i.state);
1072   }
1073
1074   handleReportSubmit(i: CommentNode) {
1075     let comment = i.props.node.comment_view.comment;
1076     let form: CreateCommentReport = {
1077       comment_id: comment.id,
1078       reason: i.state.reportReason,
1079       auth: authField(),
1080     };
1081     WebSocketService.Instance.send(wsClient.createCommentReport(form));
1082
1083     i.state.showReportDialog = false;
1084     i.setState(i.state);
1085   }
1086
1087   handleModRemoveShow(i: CommentNode) {
1088     i.state.showRemoveDialog = !i.state.showRemoveDialog;
1089     i.state.showBanDialog = false;
1090     i.setState(i.state);
1091   }
1092
1093   handleModRemoveReasonChange(i: CommentNode, event: any) {
1094     i.state.removeReason = event.target.value;
1095     i.setState(i.state);
1096   }
1097
1098   handleModRemoveDataChange(i: CommentNode, event: any) {
1099     i.state.removeData = event.target.checked;
1100     i.setState(i.state);
1101   }
1102
1103   handleModRemoveSubmit(i: CommentNode) {
1104     let comment = i.props.node.comment_view.comment;
1105     let form: RemoveComment = {
1106       comment_id: comment.id,
1107       removed: !comment.removed,
1108       reason: i.state.removeReason,
1109       auth: authField(),
1110     };
1111     WebSocketService.Instance.send(wsClient.removeComment(form));
1112
1113     i.state.showRemoveDialog = false;
1114     i.setState(i.state);
1115   }
1116
1117   isPersonMentionType(
1118     item: CommentView | PersonMentionView
1119   ): item is PersonMentionView {
1120     return (item as PersonMentionView).person_mention?.id !== undefined;
1121   }
1122
1123   handleMarkRead(i: CommentNode) {
1124     if (i.isPersonMentionType(i.props.node.comment_view)) {
1125       let form: MarkPersonMentionAsRead = {
1126         person_mention_id: i.props.node.comment_view.person_mention.id,
1127         read: !i.props.node.comment_view.person_mention.read,
1128         auth: authField(),
1129       };
1130       WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
1131     } else {
1132       let form: MarkCommentAsRead = {
1133         comment_id: i.props.node.comment_view.comment.id,
1134         read: !i.props.node.comment_view.comment.read,
1135         auth: authField(),
1136       };
1137       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
1138     }
1139
1140     i.state.readLoading = true;
1141     i.setState(this.state);
1142   }
1143
1144   handleModBanFromCommunityShow(i: CommentNode) {
1145     i.state.showBanDialog = true;
1146     i.state.banType = BanType.Community;
1147     i.state.showRemoveDialog = false;
1148     i.setState(i.state);
1149   }
1150
1151   handleModBanShow(i: CommentNode) {
1152     i.state.showBanDialog = true;
1153     i.state.banType = BanType.Site;
1154     i.state.showRemoveDialog = false;
1155     i.setState(i.state);
1156   }
1157
1158   handleModBanReasonChange(i: CommentNode, event: any) {
1159     i.state.banReason = event.target.value;
1160     i.setState(i.state);
1161   }
1162
1163   handleModBanExpireDaysChange(i: CommentNode, event: any) {
1164     i.state.banExpireDays = event.target.value;
1165     i.setState(i.state);
1166   }
1167
1168   handleModBanFromCommunitySubmit(i: CommentNode) {
1169     i.state.banType = BanType.Community;
1170     i.setState(i.state);
1171     i.handleModBanBothSubmit(i);
1172   }
1173
1174   handleModBanSubmit(i: CommentNode) {
1175     i.state.banType = BanType.Site;
1176     i.setState(i.state);
1177     i.handleModBanBothSubmit(i);
1178   }
1179
1180   handleModBanBothSubmit(i: CommentNode) {
1181     let cv = i.props.node.comment_view;
1182
1183     if (i.state.banType == BanType.Community) {
1184       // If its an unban, restore all their data
1185       let ban = !cv.creator_banned_from_community;
1186       if (ban == false) {
1187         i.state.removeData = false;
1188       }
1189       let form: BanFromCommunity = {
1190         person_id: cv.creator.id,
1191         community_id: cv.community.id,
1192         ban,
1193         remove_data: i.state.removeData,
1194         reason: i.state.banReason,
1195         expires: futureDaysToUnixTime(i.state.banExpireDays),
1196         auth: authField(),
1197       };
1198       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1199     } else {
1200       // If its an unban, restore all their data
1201       let ban = !cv.creator.banned;
1202       if (ban == false) {
1203         i.state.removeData = false;
1204       }
1205       let form: BanPerson = {
1206         person_id: cv.creator.id,
1207         ban,
1208         remove_data: i.state.removeData,
1209         reason: i.state.banReason,
1210         expires: futureDaysToUnixTime(i.state.banExpireDays),
1211         auth: authField(),
1212       };
1213       WebSocketService.Instance.send(wsClient.banPerson(form));
1214     }
1215
1216     i.state.showBanDialog = false;
1217     i.setState(i.state);
1218   }
1219
1220   handleShowConfirmAppointAsMod(i: CommentNode) {
1221     i.state.showConfirmAppointAsMod = true;
1222     i.setState(i.state);
1223   }
1224
1225   handleCancelConfirmAppointAsMod(i: CommentNode) {
1226     i.state.showConfirmAppointAsMod = false;
1227     i.setState(i.state);
1228   }
1229
1230   handleAddModToCommunity(i: CommentNode) {
1231     let cv = i.props.node.comment_view;
1232     let form: AddModToCommunity = {
1233       person_id: cv.creator.id,
1234       community_id: cv.community.id,
1235       added: !i.isMod,
1236       auth: authField(),
1237     };
1238     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1239     i.state.showConfirmAppointAsMod = false;
1240     i.setState(i.state);
1241   }
1242
1243   handleShowConfirmAppointAsAdmin(i: CommentNode) {
1244     i.state.showConfirmAppointAsAdmin = true;
1245     i.setState(i.state);
1246   }
1247
1248   handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1249     i.state.showConfirmAppointAsAdmin = false;
1250     i.setState(i.state);
1251   }
1252
1253   handleAddAdmin(i: CommentNode) {
1254     let form: AddAdmin = {
1255       person_id: i.props.node.comment_view.creator.id,
1256       added: !i.isAdmin,
1257       auth: authField(),
1258     };
1259     WebSocketService.Instance.send(wsClient.addAdmin(form));
1260     i.state.showConfirmAppointAsAdmin = false;
1261     i.setState(i.state);
1262   }
1263
1264   handleShowConfirmTransferCommunity(i: CommentNode) {
1265     i.state.showConfirmTransferCommunity = true;
1266     i.setState(i.state);
1267   }
1268
1269   handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1270     i.state.showConfirmTransferCommunity = false;
1271     i.setState(i.state);
1272   }
1273
1274   handleTransferCommunity(i: CommentNode) {
1275     let cv = i.props.node.comment_view;
1276     let form: TransferCommunity = {
1277       community_id: cv.community.id,
1278       person_id: cv.creator.id,
1279       auth: authField(),
1280     };
1281     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1282     i.state.showConfirmTransferCommunity = false;
1283     i.setState(i.state);
1284   }
1285
1286   handleShowConfirmTransferSite(i: CommentNode) {
1287     i.state.showConfirmTransferSite = true;
1288     i.setState(i.state);
1289   }
1290
1291   handleCancelShowConfirmTransferSite(i: CommentNode) {
1292     i.state.showConfirmTransferSite = false;
1293     i.setState(i.state);
1294   }
1295
1296   get isCommentNew(): boolean {
1297     let now = moment.utc().subtract(10, "minutes");
1298     let then = moment.utc(this.props.node.comment_view.comment.published);
1299     return now.isBefore(then);
1300   }
1301
1302   handleCommentCollapse(i: CommentNode) {
1303     i.state.collapsed = !i.state.collapsed;
1304     i.setState(i.state);
1305     setupTippy();
1306   }
1307
1308   handleViewSource(i: CommentNode) {
1309     i.state.viewSource = !i.state.viewSource;
1310     i.setState(i.state);
1311   }
1312
1313   handleShowAdvanced(i: CommentNode) {
1314     i.state.showAdvanced = !i.state.showAdvanced;
1315     i.setState(i.state);
1316     setupTippy();
1317   }
1318
1319   get scoreColor() {
1320     if (this.state.my_vote == 1) {
1321       return "text-info";
1322     } else if (this.state.my_vote == -1) {
1323       return "text-danger";
1324     } else {
1325       return "text-muted";
1326     }
1327   }
1328
1329   get pointsTippy(): string {
1330     let points = i18n.t("number_of_points", {
1331       count: this.state.score,
1332       formattedCount: this.state.score,
1333     });
1334
1335     let upvotes = i18n.t("number_of_upvotes", {
1336       count: this.state.upvotes,
1337       formattedCount: this.state.upvotes,
1338     });
1339
1340     let downvotes = i18n.t("number_of_downvotes", {
1341       count: this.state.downvotes,
1342       formattedCount: this.state.downvotes,
1343     });
1344
1345     return `${points} • ${upvotes} • ${downvotes}`;
1346   }
1347
1348   get expandText(): string {
1349     return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
1350   }
1351 }