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