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