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