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