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