]> Untitled Git - lemmy-ui.git/blob - src/shared/components/comment/comment-node.tsx
Add federated post and comment links. Fixes #569 (#575)
[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         {/* TODO comment ap_ids are currently broken anyway, so use post.ap_id, and wait until comment tree / endpoint refactor */}
871         {!cv.comment.local && (
872           <a className={classnames} title={title} href={cv.post.ap_id}>
873             <Icon icon="fedilink" classes="icon-inline" />
874           </a>
875         )}
876       </>
877     );
878   }
879
880   get loadingIcon() {
881     return <Spinner />;
882   }
883
884   get myComment(): boolean {
885     return (
886       this.props.node.comment_view.creator.id ==
887       UserService.Instance.myUserInfo?.local_user_view.person.id
888     );
889   }
890
891   get isMod(): boolean {
892     return (
893       this.props.moderators &&
894       isMod(
895         this.props.moderators.map(m => m.moderator.id),
896         this.props.node.comment_view.creator.id
897       )
898     );
899   }
900
901   get isAdmin(): boolean {
902     return (
903       this.props.admins &&
904       isMod(
905         this.props.admins.map(a => a.person.id),
906         this.props.node.comment_view.creator.id
907       )
908     );
909   }
910
911   get isPostCreator(): boolean {
912     return this.props.node.comment_view.creator.id == this.props.postCreatorId;
913   }
914
915   get canMod(): boolean {
916     if (this.props.admins && this.props.moderators) {
917       let adminsThenMods = this.props.admins
918         .map(a => a.person.id)
919         .concat(this.props.moderators.map(m => m.moderator.id));
920
921       return canMod(
922         UserService.Instance.myUserInfo,
923         adminsThenMods,
924         this.props.node.comment_view.creator.id
925       );
926     } else {
927       return false;
928     }
929   }
930
931   get canAdmin(): boolean {
932     return (
933       this.props.admins &&
934       canMod(
935         UserService.Instance.myUserInfo,
936         this.props.admins.map(a => a.person.id),
937         this.props.node.comment_view.creator.id
938       )
939     );
940   }
941
942   get amCommunityCreator(): boolean {
943     return (
944       this.props.moderators &&
945       UserService.Instance.myUserInfo &&
946       this.props.node.comment_view.creator.id !=
947         UserService.Instance.myUserInfo.local_user_view.person.id &&
948       UserService.Instance.myUserInfo.local_user_view.person.id ==
949         this.props.moderators[0].moderator.id
950     );
951   }
952
953   get amSiteCreator(): boolean {
954     return (
955       this.props.admins &&
956       UserService.Instance.myUserInfo &&
957       this.props.node.comment_view.creator.id !=
958         UserService.Instance.myUserInfo.local_user_view.person.id &&
959       UserService.Instance.myUserInfo.local_user_view.person.id ==
960         this.props.admins[0].person.id
961     );
962   }
963
964   get commentUnlessRemoved(): string {
965     let comment = this.props.node.comment_view.comment;
966     return comment.removed
967       ? `*${i18n.t("removed")}*`
968       : comment.deleted
969       ? `*${i18n.t("deleted")}*`
970       : comment.content;
971   }
972
973   handleReplyClick(i: CommentNode) {
974     i.state.showReply = true;
975     i.setState(i.state);
976   }
977
978   handleEditClick(i: CommentNode) {
979     i.state.showEdit = true;
980     i.setState(i.state);
981   }
982
983   handleBlockUserClick(i: CommentNode) {
984     let blockUserForm: BlockPerson = {
985       person_id: i.props.node.comment_view.creator.id,
986       block: true,
987       auth: authField(),
988     };
989     WebSocketService.Instance.send(wsClient.blockPerson(blockUserForm));
990   }
991
992   handleDeleteClick(i: CommentNode) {
993     let comment = i.props.node.comment_view.comment;
994     let deleteForm: DeleteComment = {
995       comment_id: comment.id,
996       deleted: !comment.deleted,
997       auth: authField(),
998     };
999     WebSocketService.Instance.send(wsClient.deleteComment(deleteForm));
1000   }
1001
1002   handleSaveCommentClick(i: CommentNode) {
1003     let cv = i.props.node.comment_view;
1004     let save = cv.saved == undefined ? true : !cv.saved;
1005     let form: SaveComment = {
1006       comment_id: cv.comment.id,
1007       save,
1008       auth: authField(),
1009     };
1010
1011     WebSocketService.Instance.send(wsClient.saveComment(form));
1012
1013     i.state.saveLoading = true;
1014     i.setState(this.state);
1015   }
1016
1017   handleReplyCancel() {
1018     this.state.showReply = false;
1019     this.state.showEdit = false;
1020     this.setState(this.state);
1021   }
1022
1023   handleCommentUpvote(i: CommentNodeI, event: any) {
1024     event.preventDefault();
1025     let new_vote = this.state.my_vote == 1 ? 0 : 1;
1026
1027     if (this.state.my_vote == 1) {
1028       this.state.score--;
1029       this.state.upvotes--;
1030     } else if (this.state.my_vote == -1) {
1031       this.state.downvotes--;
1032       this.state.upvotes++;
1033       this.state.score += 2;
1034     } else {
1035       this.state.upvotes++;
1036       this.state.score++;
1037     }
1038
1039     this.state.my_vote = new_vote;
1040
1041     let form: CreateCommentLike = {
1042       comment_id: i.comment_view.comment.id,
1043       score: this.state.my_vote,
1044       auth: authField(),
1045     };
1046
1047     WebSocketService.Instance.send(wsClient.likeComment(form));
1048     this.setState(this.state);
1049     setupTippy();
1050   }
1051
1052   handleCommentDownvote(i: CommentNodeI, event: any) {
1053     event.preventDefault();
1054     let new_vote = this.state.my_vote == -1 ? 0 : -1;
1055
1056     if (this.state.my_vote == 1) {
1057       this.state.score -= 2;
1058       this.state.upvotes--;
1059       this.state.downvotes++;
1060     } else if (this.state.my_vote == -1) {
1061       this.state.downvotes--;
1062       this.state.score++;
1063     } else {
1064       this.state.downvotes++;
1065       this.state.score--;
1066     }
1067
1068     this.state.my_vote = new_vote;
1069
1070     let form: CreateCommentLike = {
1071       comment_id: i.comment_view.comment.id,
1072       score: this.state.my_vote,
1073       auth: authField(),
1074     };
1075
1076     WebSocketService.Instance.send(wsClient.likeComment(form));
1077     this.setState(this.state);
1078     setupTippy();
1079   }
1080
1081   handleShowReportDialog(i: CommentNode) {
1082     i.state.showReportDialog = !i.state.showReportDialog;
1083     i.setState(i.state);
1084   }
1085
1086   handleReportReasonChange(i: CommentNode, event: any) {
1087     i.state.reportReason = event.target.value;
1088     i.setState(i.state);
1089   }
1090
1091   handleReportSubmit(i: CommentNode) {
1092     let comment = i.props.node.comment_view.comment;
1093     let form: CreateCommentReport = {
1094       comment_id: comment.id,
1095       reason: i.state.reportReason,
1096       auth: authField(),
1097     };
1098     WebSocketService.Instance.send(wsClient.createCommentReport(form));
1099
1100     i.state.showReportDialog = false;
1101     i.setState(i.state);
1102   }
1103
1104   handleModRemoveShow(i: CommentNode) {
1105     i.state.showRemoveDialog = !i.state.showRemoveDialog;
1106     i.state.showBanDialog = false;
1107     i.setState(i.state);
1108   }
1109
1110   handleModRemoveReasonChange(i: CommentNode, event: any) {
1111     i.state.removeReason = event.target.value;
1112     i.setState(i.state);
1113   }
1114
1115   handleModRemoveDataChange(i: CommentNode, event: any) {
1116     i.state.removeData = event.target.checked;
1117     i.setState(i.state);
1118   }
1119
1120   handleModRemoveSubmit(i: CommentNode) {
1121     let comment = i.props.node.comment_view.comment;
1122     let form: RemoveComment = {
1123       comment_id: comment.id,
1124       removed: !comment.removed,
1125       reason: i.state.removeReason,
1126       auth: authField(),
1127     };
1128     WebSocketService.Instance.send(wsClient.removeComment(form));
1129
1130     i.state.showRemoveDialog = false;
1131     i.setState(i.state);
1132   }
1133
1134   isPersonMentionType(
1135     item: CommentView | PersonMentionView
1136   ): item is PersonMentionView {
1137     return (item as PersonMentionView).person_mention?.id !== undefined;
1138   }
1139
1140   handleMarkRead(i: CommentNode) {
1141     if (i.isPersonMentionType(i.props.node.comment_view)) {
1142       let form: MarkPersonMentionAsRead = {
1143         person_mention_id: i.props.node.comment_view.person_mention.id,
1144         read: !i.props.node.comment_view.person_mention.read,
1145         auth: authField(),
1146       };
1147       WebSocketService.Instance.send(wsClient.markPersonMentionAsRead(form));
1148     } else {
1149       let form: MarkCommentAsRead = {
1150         comment_id: i.props.node.comment_view.comment.id,
1151         read: !i.props.node.comment_view.comment.read,
1152         auth: authField(),
1153       };
1154       WebSocketService.Instance.send(wsClient.markCommentAsRead(form));
1155     }
1156
1157     i.state.readLoading = true;
1158     i.setState(this.state);
1159   }
1160
1161   handleModBanFromCommunityShow(i: CommentNode) {
1162     i.state.showBanDialog = true;
1163     i.state.banType = BanType.Community;
1164     i.state.showRemoveDialog = false;
1165     i.setState(i.state);
1166   }
1167
1168   handleModBanShow(i: CommentNode) {
1169     i.state.showBanDialog = true;
1170     i.state.banType = BanType.Site;
1171     i.state.showRemoveDialog = false;
1172     i.setState(i.state);
1173   }
1174
1175   handleModBanReasonChange(i: CommentNode, event: any) {
1176     i.state.banReason = event.target.value;
1177     i.setState(i.state);
1178   }
1179
1180   handleModBanExpireDaysChange(i: CommentNode, event: any) {
1181     i.state.banExpireDays = event.target.value;
1182     i.setState(i.state);
1183   }
1184
1185   handleModBanFromCommunitySubmit(i: CommentNode) {
1186     i.state.banType = BanType.Community;
1187     i.setState(i.state);
1188     i.handleModBanBothSubmit(i);
1189   }
1190
1191   handleModBanSubmit(i: CommentNode) {
1192     i.state.banType = BanType.Site;
1193     i.setState(i.state);
1194     i.handleModBanBothSubmit(i);
1195   }
1196
1197   handleModBanBothSubmit(i: CommentNode) {
1198     let cv = i.props.node.comment_view;
1199
1200     if (i.state.banType == BanType.Community) {
1201       // If its an unban, restore all their data
1202       let ban = !cv.creator_banned_from_community;
1203       if (ban == false) {
1204         i.state.removeData = false;
1205       }
1206       let form: BanFromCommunity = {
1207         person_id: cv.creator.id,
1208         community_id: cv.community.id,
1209         ban,
1210         remove_data: i.state.removeData,
1211         reason: i.state.banReason,
1212         expires: futureDaysToUnixTime(i.state.banExpireDays),
1213         auth: authField(),
1214       };
1215       WebSocketService.Instance.send(wsClient.banFromCommunity(form));
1216     } else {
1217       // If its an unban, restore all their data
1218       let ban = !cv.creator.banned;
1219       if (ban == false) {
1220         i.state.removeData = false;
1221       }
1222       let form: BanPerson = {
1223         person_id: cv.creator.id,
1224         ban,
1225         remove_data: i.state.removeData,
1226         reason: i.state.banReason,
1227         expires: futureDaysToUnixTime(i.state.banExpireDays),
1228         auth: authField(),
1229       };
1230       WebSocketService.Instance.send(wsClient.banPerson(form));
1231     }
1232
1233     i.state.showBanDialog = false;
1234     i.setState(i.state);
1235   }
1236
1237   handleShowConfirmAppointAsMod(i: CommentNode) {
1238     i.state.showConfirmAppointAsMod = true;
1239     i.setState(i.state);
1240   }
1241
1242   handleCancelConfirmAppointAsMod(i: CommentNode) {
1243     i.state.showConfirmAppointAsMod = false;
1244     i.setState(i.state);
1245   }
1246
1247   handleAddModToCommunity(i: CommentNode) {
1248     let cv = i.props.node.comment_view;
1249     let form: AddModToCommunity = {
1250       person_id: cv.creator.id,
1251       community_id: cv.community.id,
1252       added: !i.isMod,
1253       auth: authField(),
1254     };
1255     WebSocketService.Instance.send(wsClient.addModToCommunity(form));
1256     i.state.showConfirmAppointAsMod = false;
1257     i.setState(i.state);
1258   }
1259
1260   handleShowConfirmAppointAsAdmin(i: CommentNode) {
1261     i.state.showConfirmAppointAsAdmin = true;
1262     i.setState(i.state);
1263   }
1264
1265   handleCancelConfirmAppointAsAdmin(i: CommentNode) {
1266     i.state.showConfirmAppointAsAdmin = false;
1267     i.setState(i.state);
1268   }
1269
1270   handleAddAdmin(i: CommentNode) {
1271     let form: AddAdmin = {
1272       person_id: i.props.node.comment_view.creator.id,
1273       added: !i.isAdmin,
1274       auth: authField(),
1275     };
1276     WebSocketService.Instance.send(wsClient.addAdmin(form));
1277     i.state.showConfirmAppointAsAdmin = false;
1278     i.setState(i.state);
1279   }
1280
1281   handleShowConfirmTransferCommunity(i: CommentNode) {
1282     i.state.showConfirmTransferCommunity = true;
1283     i.setState(i.state);
1284   }
1285
1286   handleCancelShowConfirmTransferCommunity(i: CommentNode) {
1287     i.state.showConfirmTransferCommunity = false;
1288     i.setState(i.state);
1289   }
1290
1291   handleTransferCommunity(i: CommentNode) {
1292     let cv = i.props.node.comment_view;
1293     let form: TransferCommunity = {
1294       community_id: cv.community.id,
1295       person_id: cv.creator.id,
1296       auth: authField(),
1297     };
1298     WebSocketService.Instance.send(wsClient.transferCommunity(form));
1299     i.state.showConfirmTransferCommunity = false;
1300     i.setState(i.state);
1301   }
1302
1303   handleShowConfirmTransferSite(i: CommentNode) {
1304     i.state.showConfirmTransferSite = true;
1305     i.setState(i.state);
1306   }
1307
1308   handleCancelShowConfirmTransferSite(i: CommentNode) {
1309     i.state.showConfirmTransferSite = false;
1310     i.setState(i.state);
1311   }
1312
1313   get isCommentNew(): boolean {
1314     let now = moment.utc().subtract(10, "minutes");
1315     let then = moment.utc(this.props.node.comment_view.comment.published);
1316     return now.isBefore(then);
1317   }
1318
1319   handleCommentCollapse(i: CommentNode) {
1320     i.state.collapsed = !i.state.collapsed;
1321     i.setState(i.state);
1322     setupTippy();
1323   }
1324
1325   handleViewSource(i: CommentNode) {
1326     i.state.viewSource = !i.state.viewSource;
1327     i.setState(i.state);
1328   }
1329
1330   handleShowAdvanced(i: CommentNode) {
1331     i.state.showAdvanced = !i.state.showAdvanced;
1332     i.setState(i.state);
1333     setupTippy();
1334   }
1335
1336   get scoreColor() {
1337     if (this.state.my_vote == 1) {
1338       return "text-info";
1339     } else if (this.state.my_vote == -1) {
1340       return "text-danger";
1341     } else {
1342       return "text-muted";
1343     }
1344   }
1345
1346   get pointsTippy(): string {
1347     let points = i18n.t("number_of_points", {
1348       count: this.state.score,
1349       formattedCount: this.state.score,
1350     });
1351
1352     let upvotes = i18n.t("number_of_upvotes", {
1353       count: this.state.upvotes,
1354       formattedCount: this.state.upvotes,
1355     });
1356
1357     let downvotes = i18n.t("number_of_downvotes", {
1358       count: this.state.downvotes,
1359       formattedCount: this.state.downvotes,
1360     });
1361
1362     return `${points} • ${upvotes} • ${downvotes}`;
1363   }
1364
1365   get expandText(): string {
1366     return this.state.collapsed ? i18n.t("expand") : i18n.t("collapse");
1367   }
1368 }