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