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