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