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