]> Untitled Git - lemmy.git/blob - ui/src/components/post-listing.tsx
Merge branch 'main' into api_edit_separation
[lemmy.git] / ui / src / components / post-listing.tsx
1 import { Component, linkEvent } from 'inferno';
2 import { Link } from 'inferno-router';
3 import { WebSocketService, UserService } from '../services';
4 import {
5   Post,
6   CreatePostLikeForm,
7   DeletePostForm,
8   RemovePostForm,
9   LockPostForm,
10   StickyPostForm,
11   SavePostForm,
12   CommunityUser,
13   UserView,
14   BanType,
15   BanFromCommunityForm,
16   BanUserForm,
17   AddModToCommunityForm,
18   AddAdminForm,
19   TransferSiteForm,
20   TransferCommunityForm,
21 } from '../interfaces';
22 import { MomentTime } from './moment-time';
23 import { PostForm } from './post-form';
24 import { IFramelyCard } from './iframely-card';
25 import { UserListing } from './user-listing';
26 import { CommunityLink } from './community-link';
27 import {
28   md,
29   mdToHtml,
30   canMod,
31   isMod,
32   isImage,
33   isVideo,
34   getUnixTime,
35   pictrsImage,
36   setupTippy,
37   hostname,
38   previewLines,
39 } from '../utils';
40 import { i18n } from '../i18next';
41
42 interface PostListingState {
43   showEdit: boolean;
44   showRemoveDialog: boolean;
45   removeReason: string;
46   showBanDialog: boolean;
47   banReason: string;
48   banExpires: string;
49   banType: BanType;
50   showConfirmTransferSite: boolean;
51   showConfirmTransferCommunity: boolean;
52   imageExpanded: boolean;
53   viewSource: boolean;
54   showAdvanced: boolean;
55   my_vote: number;
56   score: number;
57   upvotes: number;
58   downvotes: number;
59 }
60
61 interface PostListingProps {
62   post: Post;
63   showCommunity?: boolean;
64   showBody?: boolean;
65   moderators?: Array<CommunityUser>;
66   admins?: Array<UserView>;
67   enableDownvotes: boolean;
68   enableNsfw: boolean;
69 }
70
71 export class PostListing extends Component<PostListingProps, PostListingState> {
72   private emptyState: PostListingState = {
73     showEdit: false,
74     showRemoveDialog: false,
75     removeReason: null,
76     showBanDialog: false,
77     banReason: null,
78     banExpires: null,
79     banType: BanType.Community,
80     showConfirmTransferSite: false,
81     showConfirmTransferCommunity: false,
82     imageExpanded: false,
83     viewSource: false,
84     showAdvanced: false,
85     my_vote: this.props.post.my_vote,
86     score: this.props.post.score,
87     upvotes: this.props.post.upvotes,
88     downvotes: this.props.post.downvotes,
89   };
90
91   constructor(props: any, context: any) {
92     super(props, context);
93
94     this.state = this.emptyState;
95     this.handlePostLike = this.handlePostLike.bind(this);
96     this.handlePostDisLike = this.handlePostDisLike.bind(this);
97     this.handleEditPost = this.handleEditPost.bind(this);
98     this.handleEditCancel = this.handleEditCancel.bind(this);
99   }
100
101   componentWillReceiveProps(nextProps: PostListingProps) {
102     this.state.my_vote = nextProps.post.my_vote;
103     this.state.upvotes = nextProps.post.upvotes;
104     this.state.downvotes = nextProps.post.downvotes;
105     this.state.score = nextProps.post.score;
106     if (this.props.post.id !== nextProps.post.id) {
107       this.state.imageExpanded = false;
108     }
109     this.setState(this.state);
110   }
111
112   render() {
113     return (
114       <div class="">
115         {!this.state.showEdit ? (
116           <>
117             {this.listing()}
118             {this.body()}
119           </>
120         ) : (
121           <div class="col-12">
122             <PostForm
123               post={this.props.post}
124               onEdit={this.handleEditPost}
125               onCancel={this.handleEditCancel}
126               enableNsfw={this.props.enableNsfw}
127               enableDownvotes={this.props.enableDownvotes}
128             />
129           </div>
130         )}
131       </div>
132     );
133   }
134
135   body() {
136     return (
137       <div class="row">
138         <div class="col-12">
139           {this.props.post.url &&
140             this.props.showBody &&
141             this.props.post.embed_title && (
142               <IFramelyCard post={this.props.post} />
143             )}
144           {this.props.showBody && this.props.post.body && (
145             <>
146               {this.state.viewSource ? (
147                 <pre>{this.props.post.body}</pre>
148               ) : (
149                 <div
150                   className="md-div"
151                   dangerouslySetInnerHTML={mdToHtml(this.props.post.body)}
152                 />
153               )}
154             </>
155           )}
156         </div>
157       </div>
158     );
159   }
160
161   imgThumb(src: string) {
162     let post = this.props.post;
163     return (
164       <img
165         className={`img-fluid thumbnail rounded ${
166           (post.nsfw || post.community_nsfw) && 'img-blur'
167         }`}
168         src={src}
169       />
170     );
171   }
172
173   getImage(thumbnail: boolean = false) {
174     let post = this.props.post;
175     if (isImage(post.url)) {
176       if (post.url.includes('pictrs')) {
177         return pictrsImage(post.url, thumbnail);
178       } else if (post.thumbnail_url) {
179         return pictrsImage(post.thumbnail_url, thumbnail);
180       } else {
181         return post.url;
182       }
183     } else if (post.thumbnail_url) {
184       return pictrsImage(post.thumbnail_url, thumbnail);
185     }
186   }
187
188   thumbnail() {
189     let post = this.props.post;
190
191     if (isImage(post.url)) {
192       return (
193         <span
194           class="text-body pointer"
195           data-tippy-content={i18n.t('expand_here')}
196           onClick={linkEvent(this, this.handleImageExpandClick)}
197         >
198           {this.imgThumb(this.getImage(true))}
199           <svg class="icon mini-overlay">
200             <use xlinkHref="#icon-image"></use>
201           </svg>
202         </span>
203       );
204     } else if (post.thumbnail_url) {
205       return (
206         <a
207           className="text-body"
208           href={post.url}
209           target="_blank"
210           rel="noopener"
211           title={post.url}
212         >
213           {this.imgThumb(this.getImage(true))}
214           <svg class="icon mini-overlay">
215             <use xlinkHref="#icon-external-link"></use>
216           </svg>
217         </a>
218       );
219     } else if (post.url) {
220       if (isVideo(post.url)) {
221         return (
222           <div class="embed-responsive embed-responsive-16by9">
223             <video
224               playsinline
225               muted
226               loop
227               controls
228               class="embed-responsive-item"
229             >
230               <source src={post.url} type="video/mp4" />
231             </video>
232           </div>
233         );
234       } else {
235         return (
236           <a
237             className="text-body"
238             href={post.url}
239             target="_blank"
240             title={post.url}
241             rel="noopener"
242           >
243             <svg class="icon thumbnail">
244               <use xlinkHref="#icon-external-link"></use>
245             </svg>
246           </a>
247         );
248       }
249     } else {
250       return (
251         <Link
252           className="text-body"
253           to={`/post/${post.id}`}
254           title={i18n.t('comments')}
255         >
256           <svg class="icon thumbnail">
257             <use xlinkHref="#icon-message-square"></use>
258           </svg>
259         </Link>
260       );
261     }
262   }
263
264   listing() {
265     let post = this.props.post;
266     return (
267       <div class="row">
268         <div className={`vote-bar col-1 pr-0 small text-center`}>
269           <button
270             className={`btn-animate btn btn-link p-0 ${
271               this.state.my_vote == 1 ? 'text-info' : 'text-muted'
272             }`}
273             onClick={linkEvent(this, this.handlePostLike)}
274             data-tippy-content={i18n.t('upvote')}
275           >
276             <svg class="icon upvote">
277               <use xlinkHref="#icon-arrow-up1"></use>
278             </svg>
279           </button>
280           <div
281             class={`unselectable pointer font-weight-bold text-muted px-1`}
282             data-tippy-content={this.pointsTippy}
283           >
284             {this.state.score}
285           </div>
286           {this.props.enableDownvotes && (
287             <button
288               className={`btn-animate btn btn-link p-0 ${
289                 this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
290               }`}
291               onClick={linkEvent(this, this.handlePostDisLike)}
292               data-tippy-content={i18n.t('downvote')}
293             >
294               <svg class="icon downvote">
295                 <use xlinkHref="#icon-arrow-down1"></use>
296               </svg>
297             </button>
298           )}
299         </div>
300         {!this.state.imageExpanded && (
301           <div class="col-3 col-sm-2 pr-0 mt-1">
302             <div class="position-relative">{this.thumbnail()}</div>
303           </div>
304         )}
305         <div
306           class={`${this.state.imageExpanded ? 'col-12' : 'col-8 col-sm-9'}`}
307         >
308           <div class="row">
309             <div className="col-12">
310               <div className="post-title">
311                 <h5 className="mb-0 d-inline">
312                   {this.props.showBody && post.url ? (
313                     <a
314                       className="text-body"
315                       href={post.url}
316                       target="_blank"
317                       title={post.url}
318                       rel="noopener"
319                     >
320                       {post.name}
321                     </a>
322                   ) : (
323                     <Link
324                       className="text-body"
325                       to={`/post/${post.id}`}
326                       title={i18n.t('comments')}
327                     >
328                       {post.name}
329                     </Link>
330                   )}
331                 </h5>
332                 {post.url && !(hostname(post.url) == window.location.hostname) && (
333                   <small class="d-inline-block">
334                     <a
335                       className="ml-2 text-muted font-italic"
336                       href={post.url}
337                       target="_blank"
338                       title={post.url}
339                       rel="noopener"
340                     >
341                       {hostname(post.url)}
342                       <svg class="ml-1 icon icon-inline">
343                         <use xlinkHref="#icon-external-link"></use>
344                       </svg>
345                     </a>
346                   </small>
347                 )}
348                 {(isImage(post.url) || this.props.post.thumbnail_url) && (
349                   <>
350                     {!this.state.imageExpanded ? (
351                       <span
352                         class="text-monospace unselectable pointer ml-2 text-muted small"
353                         data-tippy-content={i18n.t('expand_here')}
354                         onClick={linkEvent(this, this.handleImageExpandClick)}
355                       >
356                         <svg class="icon icon-inline">
357                           <use xlinkHref="#icon-plus-square"></use>
358                         </svg>
359                       </span>
360                     ) : (
361                       <span>
362                         <span
363                           class="text-monospace unselectable pointer ml-2 text-muted small"
364                           onClick={linkEvent(this, this.handleImageExpandClick)}
365                         >
366                           <svg class="icon icon-inline">
367                             <use xlinkHref="#icon-minus-square"></use>
368                           </svg>
369                         </span>
370                         <div>
371                           <span
372                             class="pointer"
373                             onClick={linkEvent(
374                               this,
375                               this.handleImageExpandClick
376                             )}
377                           >
378                             <img
379                               class="img-fluid img-expanded"
380                               src={this.getImage()}
381                             />
382                           </span>
383                         </div>
384                       </span>
385                     )}
386                   </>
387                 )}
388                 {post.removed && (
389                   <small className="ml-2 text-muted font-italic">
390                     {i18n.t('removed')}
391                   </small>
392                 )}
393                 {post.deleted && (
394                   <small
395                     className="unselectable pointer ml-2 text-muted font-italic"
396                     data-tippy-content={i18n.t('deleted')}
397                   >
398                     <svg class={`icon icon-inline text-danger`}>
399                       <use xlinkHref="#icon-trash"></use>
400                     </svg>
401                   </small>
402                 )}
403                 {post.locked && (
404                   <small
405                     className="unselectable pointer ml-2 text-muted font-italic"
406                     data-tippy-content={i18n.t('locked')}
407                   >
408                     <svg class={`icon icon-inline text-danger`}>
409                       <use xlinkHref="#icon-lock"></use>
410                     </svg>
411                   </small>
412                 )}
413                 {post.stickied && (
414                   <small
415                     className="unselectable pointer ml-2 text-muted font-italic"
416                     data-tippy-content={i18n.t('stickied')}
417                   >
418                     <svg class={`icon icon-inline text-success`}>
419                       <use xlinkHref="#icon-pin"></use>
420                     </svg>
421                   </small>
422                 )}
423                 {post.nsfw && (
424                   <small className="ml-2 text-muted font-italic">
425                     {i18n.t('nsfw')}
426                   </small>
427                 )}
428               </div>
429             </div>
430           </div>
431           <div class="row">
432             <div className="details col-12">
433               <ul class="list-inline mb-0 text-muted small">
434                 <li className="list-inline-item">
435                   <span>{i18n.t('by')} </span>
436                   <UserListing
437                     user={{
438                       name: post.creator_name,
439                       avatar: post.creator_avatar,
440                       id: post.creator_id,
441                       local: post.creator_local,
442                       actor_id: post.creator_actor_id,
443                       published: post.creator_published,
444                     }}
445                   />
446
447                   {this.isMod && (
448                     <span className="mx-1 badge badge-light">
449                       {i18n.t('mod')}
450                     </span>
451                   )}
452                   {this.isAdmin && (
453                     <span className="mx-1 badge badge-light">
454                       {i18n.t('admin')}
455                     </span>
456                   )}
457                   {(post.banned_from_community || post.banned) && (
458                     <span className="mx-1 badge badge-danger">
459                       {i18n.t('banned')}
460                     </span>
461                   )}
462                   {this.props.showCommunity && (
463                     <span>
464                       <span> {i18n.t('to')} </span>
465                       <CommunityLink
466                         community={{
467                           name: post.community_name,
468                           id: post.community_id,
469                           local: post.community_local,
470                           actor_id: post.community_actor_id,
471                         }}
472                       />
473                     </span>
474                   )}
475                 </li>
476                 <li className="list-inline-item">•</li>
477                 <li className="list-inline-item">
478                   <span>
479                     <MomentTime data={post} />
480                   </span>
481                 </li>
482                 {post.body && (
483                   <>
484                     <li className="list-inline-item">•</li>
485                     <li className="list-inline-item">
486                       {/* Using a link with tippy doesn't work on touch devices unfortunately */}
487                       <Link
488                         className="text-muted"
489                         data-tippy-content={md.render(previewLines(post.body))}
490                         data-tippy-allowHtml={true}
491                         to={`/post/${post.id}`}
492                       >
493                         <svg class="mr-1 icon icon-inline">
494                           <use xlinkHref="#icon-book-open"></use>
495                         </svg>
496                       </Link>
497                     </li>
498                   </>
499                 )}
500                 <li className="list-inline-item">•</li>
501                 {this.state.upvotes !== this.state.score && (
502                   <>
503                     <span
504                       class="unselectable pointer mr-2"
505                       data-tippy-content={this.pointsTippy}
506                     >
507                       <li className="list-inline-item">
508                         <span className="text-muted">
509                           <svg class="small icon icon-inline mr-1">
510                             <use xlinkHref="#icon-arrow-up"></use>
511                           </svg>
512                           {this.state.upvotes}
513                         </span>
514                       </li>
515                       <li className="list-inline-item">
516                         <span className="text-muted">
517                           <svg class="small icon icon-inline mr-1">
518                             <use xlinkHref="#icon-arrow-down"></use>
519                           </svg>
520                           {this.state.downvotes}
521                         </span>
522                       </li>
523                     </span>
524                     <li className="list-inline-item">•</li>
525                   </>
526                 )}
527                 <li className="list-inline-item">
528                   <Link
529                     className="text-muted"
530                     title={i18n.t('number_of_comments', {
531                       count: post.number_of_comments,
532                     })}
533                     to={`/post/${post.id}`}
534                   >
535                     <svg class="mr-1 icon icon-inline">
536                       <use xlinkHref="#icon-message-square"></use>
537                     </svg>
538                     {post.number_of_comments}
539                   </Link>
540                 </li>
541               </ul>
542               {this.props.post.duplicates && (
543                 <ul class="list-inline mb-1 small text-muted">
544                   <>
545                     <li className="list-inline-item mr-2">
546                       {i18n.t('cross_posted_to')}
547                     </li>
548                     {this.props.post.duplicates.map(post => (
549                       <li className="list-inline-item mr-2">
550                         <Link to={`/post/${post.id}`}>
551                           {post.community_name}
552                         </Link>
553                       </li>
554                     ))}
555                   </>
556                 </ul>
557               )}
558               <ul class="list-inline mb-1 text-muted font-weight-bold">
559                 {UserService.Instance.user && (
560                   <>
561                     {this.props.showBody && (
562                       <>
563                         <li className="list-inline-item">
564                           <button
565                             class="btn btn-sm btn-link btn-animate text-muted"
566                             onClick={linkEvent(this, this.handleSavePostClick)}
567                             data-tippy-content={
568                               post.saved ? i18n.t('unsave') : i18n.t('save')
569                             }
570                           >
571                             <svg
572                               class={`icon icon-inline ${
573                                 post.saved && 'text-warning'
574                               }`}
575                             >
576                               <use xlinkHref="#icon-star"></use>
577                             </svg>
578                           </button>
579                         </li>
580                         <li className="list-inline-item">
581                           <Link
582                             class="btn btn-sm btn-link btn-animate text-muted"
583                             to={`/create_post${this.crossPostParams}`}
584                             title={i18n.t('cross_post')}
585                           >
586                             <svg class="icon icon-inline">
587                               <use xlinkHref="#icon-copy"></use>
588                             </svg>
589                           </Link>
590                         </li>
591                       </>
592                     )}
593                     {this.myPost && this.props.showBody && (
594                       <>
595                         <li className="list-inline-item">
596                           <button
597                             class="btn btn-sm btn-link btn-animate text-muted"
598                             onClick={linkEvent(this, this.handleEditClick)}
599                             data-tippy-content={i18n.t('edit')}
600                           >
601                             <svg class="icon icon-inline">
602                               <use xlinkHref="#icon-edit"></use>
603                             </svg>
604                           </button>
605                         </li>
606                         <li className="list-inline-item">
607                           <button
608                             class="btn btn-sm btn-link btn-animate text-muted"
609                             onClick={linkEvent(this, this.handleDeleteClick)}
610                             data-tippy-content={
611                               !post.deleted
612                                 ? i18n.t('delete')
613                                 : i18n.t('restore')
614                             }
615                           >
616                             <svg
617                               class={`icon icon-inline ${
618                                 post.deleted && 'text-danger'
619                               }`}
620                             >
621                               <use xlinkHref="#icon-trash"></use>
622                             </svg>
623                           </button>
624                         </li>
625                       </>
626                     )}
627
628                     {!this.state.showAdvanced && this.props.showBody ? (
629                       <li className="list-inline-item">
630                         <button
631                           class="btn btn-sm btn-link btn-animate text-muted"
632                           onClick={linkEvent(this, this.handleShowAdvanced)}
633                           data-tippy-content={i18n.t('more')}
634                         >
635                           <svg class="icon icon-inline">
636                             <use xlinkHref="#icon-more-vertical"></use>
637                           </svg>
638                         </button>
639                       </li>
640                     ) : (
641                       <>
642                         {this.props.showBody && post.body && (
643                           <li className="list-inline-item">
644                             <button
645                               class="btn btn-sm btn-link btn-animate text-muted"
646                               onClick={linkEvent(this, this.handleViewSource)}
647                               data-tippy-content={i18n.t('view_source')}
648                             >
649                               <svg
650                                 class={`icon icon-inline ${
651                                   this.state.viewSource && 'text-success'
652                                 }`}
653                               >
654                                 <use xlinkHref="#icon-file-text"></use>
655                               </svg>
656                             </button>
657                           </li>
658                         )}
659                         {this.canModOnSelf && (
660                           <>
661                             <li className="list-inline-item">
662                               <button
663                                 class="btn btn-sm btn-link btn-animate text-muted"
664                                 onClick={linkEvent(this, this.handleModLock)}
665                                 data-tippy-content={
666                                   post.locked
667                                     ? i18n.t('unlock')
668                                     : i18n.t('lock')
669                                 }
670                               >
671                                 <svg
672                                   class={`icon icon-inline ${
673                                     post.locked && 'text-danger'
674                                   }`}
675                                 >
676                                   <use xlinkHref="#icon-lock"></use>
677                                 </svg>
678                               </button>
679                             </li>
680                             <li className="list-inline-item">
681                               <button
682                                 class="btn btn-sm btn-link btn-animate text-muted"
683                                 onClick={linkEvent(this, this.handleModSticky)}
684                                 data-tippy-content={
685                                   post.stickied
686                                     ? i18n.t('unsticky')
687                                     : i18n.t('sticky')
688                                 }
689                               >
690                                 <svg
691                                   class={`icon icon-inline ${
692                                     post.stickied && 'text-success'
693                                   }`}
694                                 >
695                                   <use xlinkHref="#icon-pin"></use>
696                                 </svg>
697                               </button>
698                             </li>
699                           </>
700                         )}
701                         {/* Mods can ban from community, and appoint as mods to community */}
702                         {(this.canMod || this.canAdmin) && (
703                           <li className="list-inline-item">
704                             {!post.removed ? (
705                               <span
706                                 class="pointer"
707                                 onClick={linkEvent(
708                                   this,
709                                   this.handleModRemoveShow
710                                 )}
711                               >
712                                 {i18n.t('remove')}
713                               </span>
714                             ) : (
715                               <span
716                                 class="pointer"
717                                 onClick={linkEvent(
718                                   this,
719                                   this.handleModRemoveSubmit
720                                 )}
721                               >
722                                 {i18n.t('restore')}
723                               </span>
724                             )}
725                           </li>
726                         )}
727                         {this.canMod && (
728                           <>
729                             {!this.isMod && (
730                               <li className="list-inline-item">
731                                 {!post.banned_from_community ? (
732                                   <span
733                                     class="pointer"
734                                     onClick={linkEvent(
735                                       this,
736                                       this.handleModBanFromCommunityShow
737                                     )}
738                                   >
739                                     {i18n.t('ban')}
740                                   </span>
741                                 ) : (
742                                   <span
743                                     class="pointer"
744                                     onClick={linkEvent(
745                                       this,
746                                       this.handleModBanFromCommunitySubmit
747                                     )}
748                                   >
749                                     {i18n.t('unban')}
750                                   </span>
751                                 )}
752                               </li>
753                             )}
754                             {!post.banned_from_community && (
755                               <li className="list-inline-item">
756                                 <span
757                                   class="pointer"
758                                   onClick={linkEvent(
759                                     this,
760                                     this.handleAddModToCommunity
761                                   )}
762                                 >
763                                   {this.isMod
764                                     ? i18n.t('remove_as_mod')
765                                     : i18n.t('appoint_as_mod')}
766                                 </span>
767                               </li>
768                             )}
769                           </>
770                         )}
771                         {/* Community creators and admins can transfer community to another mod */}
772                         {(this.amCommunityCreator || this.canAdmin) &&
773                           this.isMod && (
774                             <li className="list-inline-item">
775                               {!this.state.showConfirmTransferCommunity ? (
776                                 <span
777                                   class="pointer"
778                                   onClick={linkEvent(
779                                     this,
780                                     this.handleShowConfirmTransferCommunity
781                                   )}
782                                 >
783                                   {i18n.t('transfer_community')}
784                                 </span>
785                               ) : (
786                                 <>
787                                   <span class="d-inline-block mr-1">
788                                     {i18n.t('are_you_sure')}
789                                   </span>
790                                   <span
791                                     class="pointer d-inline-block mr-1"
792                                     onClick={linkEvent(
793                                       this,
794                                       this.handleTransferCommunity
795                                     )}
796                                   >
797                                     {i18n.t('yes')}
798                                   </span>
799                                   <span
800                                     class="pointer d-inline-block"
801                                     onClick={linkEvent(
802                                       this,
803                                       this
804                                         .handleCancelShowConfirmTransferCommunity
805                                     )}
806                                   >
807                                     {i18n.t('no')}
808                                   </span>
809                                 </>
810                               )}
811                             </li>
812                           )}
813                         {/* Admins can ban from all, and appoint other admins */}
814                         {this.canAdmin && (
815                           <>
816                             {!this.isAdmin && (
817                               <li className="list-inline-item">
818                                 {!post.banned ? (
819                                   <span
820                                     class="pointer"
821                                     onClick={linkEvent(
822                                       this,
823                                       this.handleModBanShow
824                                     )}
825                                   >
826                                     {i18n.t('ban_from_site')}
827                                   </span>
828                                 ) : (
829                                   <span
830                                     class="pointer"
831                                     onClick={linkEvent(
832                                       this,
833                                       this.handleModBanSubmit
834                                     )}
835                                   >
836                                     {i18n.t('unban_from_site')}
837                                   </span>
838                                 )}
839                               </li>
840                             )}
841                             {!post.banned && (
842                               <li className="list-inline-item">
843                                 <span
844                                   class="pointer"
845                                   onClick={linkEvent(this, this.handleAddAdmin)}
846                                 >
847                                   {this.isAdmin
848                                     ? i18n.t('remove_as_admin')
849                                     : i18n.t('appoint_as_admin')}
850                                 </span>
851                               </li>
852                             )}
853                           </>
854                         )}
855                         {/* Site Creator can transfer to another admin */}
856                         {this.amSiteCreator && this.isAdmin && (
857                           <li className="list-inline-item">
858                             {!this.state.showConfirmTransferSite ? (
859                               <span
860                                 class="pointer"
861                                 onClick={linkEvent(
862                                   this,
863                                   this.handleShowConfirmTransferSite
864                                 )}
865                               >
866                                 {i18n.t('transfer_site')}
867                               </span>
868                             ) : (
869                               <>
870                                 <span class="d-inline-block mr-1">
871                                   {i18n.t('are_you_sure')}
872                                 </span>
873                                 <span
874                                   class="pointer d-inline-block mr-1"
875                                   onClick={linkEvent(
876                                     this,
877                                     this.handleTransferSite
878                                   )}
879                                 >
880                                   {i18n.t('yes')}
881                                 </span>
882                                 <span
883                                   class="pointer d-inline-block"
884                                   onClick={linkEvent(
885                                     this,
886                                     this.handleCancelShowConfirmTransferSite
887                                   )}
888                                 >
889                                   {i18n.t('no')}
890                                 </span>
891                               </>
892                             )}
893                           </li>
894                         )}
895                       </>
896                     )}
897                   </>
898                 )}
899               </ul>
900               {this.state.showRemoveDialog && (
901                 <form
902                   class="form-inline"
903                   onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
904                 >
905                   <input
906                     type="text"
907                     class="form-control mr-2"
908                     placeholder={i18n.t('reason')}
909                     value={this.state.removeReason}
910                     onInput={linkEvent(this, this.handleModRemoveReasonChange)}
911                   />
912                   <button type="submit" class="btn btn-secondary">
913                     {i18n.t('remove_post')}
914                   </button>
915                 </form>
916               )}
917               {this.state.showBanDialog && (
918                 <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
919                   <div class="form-group row">
920                     <label class="col-form-label" htmlFor="post-listing-reason">
921                       {i18n.t('reason')}
922                     </label>
923                     <input
924                       type="text"
925                       id="post-listing-reason"
926                       class="form-control mr-2"
927                       placeholder={i18n.t('reason')}
928                       value={this.state.banReason}
929                       onInput={linkEvent(this, this.handleModBanReasonChange)}
930                     />
931                   </div>
932                   {/* TODO hold off on expires until later */}
933                   {/* <div class="form-group row"> */}
934                   {/*   <label class="col-form-label">Expires</label> */}
935                   {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
936                   {/* </div> */}
937                   <div class="form-group row">
938                     <button type="submit" class="btn btn-secondary">
939                       {i18n.t('ban')} {post.creator_name}
940                     </button>
941                   </div>
942                 </form>
943               )}
944             </div>
945           </div>
946         </div>
947       </div>
948     );
949   }
950
951   private get myPost(): boolean {
952     return (
953       UserService.Instance.user &&
954       this.props.post.creator_id == UserService.Instance.user.id
955     );
956   }
957
958   get isMod(): boolean {
959     return (
960       this.props.moderators &&
961       isMod(
962         this.props.moderators.map(m => m.user_id),
963         this.props.post.creator_id
964       )
965     );
966   }
967
968   get isAdmin(): boolean {
969     return (
970       this.props.admins &&
971       isMod(
972         this.props.admins.map(a => a.id),
973         this.props.post.creator_id
974       )
975     );
976   }
977
978   get canMod(): boolean {
979     if (this.props.admins && this.props.moderators) {
980       let adminsThenMods = this.props.admins
981         .map(a => a.id)
982         .concat(this.props.moderators.map(m => m.user_id));
983
984       return canMod(
985         UserService.Instance.user,
986         adminsThenMods,
987         this.props.post.creator_id
988       );
989     } else {
990       return false;
991     }
992   }
993
994   get canModOnSelf(): boolean {
995     if (this.props.admins && this.props.moderators) {
996       let adminsThenMods = this.props.admins
997         .map(a => a.id)
998         .concat(this.props.moderators.map(m => m.user_id));
999
1000       return canMod(
1001         UserService.Instance.user,
1002         adminsThenMods,
1003         this.props.post.creator_id,
1004         true
1005       );
1006     } else {
1007       return false;
1008     }
1009   }
1010
1011   get canAdmin(): boolean {
1012     return (
1013       this.props.admins &&
1014       canMod(
1015         UserService.Instance.user,
1016         this.props.admins.map(a => a.id),
1017         this.props.post.creator_id
1018       )
1019     );
1020   }
1021
1022   get amCommunityCreator(): boolean {
1023     return (
1024       this.props.moderators &&
1025       UserService.Instance.user &&
1026       this.props.post.creator_id != UserService.Instance.user.id &&
1027       UserService.Instance.user.id == this.props.moderators[0].user_id
1028     );
1029   }
1030
1031   get amSiteCreator(): boolean {
1032     return (
1033       this.props.admins &&
1034       UserService.Instance.user &&
1035       this.props.post.creator_id != UserService.Instance.user.id &&
1036       UserService.Instance.user.id == this.props.admins[0].id
1037     );
1038   }
1039
1040   handlePostLike(i: PostListing) {
1041     if (!UserService.Instance.user) {
1042       this.context.router.history.push(`/login`);
1043     }
1044
1045     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1046
1047     if (i.state.my_vote == 1) {
1048       i.state.score--;
1049       i.state.upvotes--;
1050     } else if (i.state.my_vote == -1) {
1051       i.state.downvotes--;
1052       i.state.upvotes++;
1053       i.state.score += 2;
1054     } else {
1055       i.state.upvotes++;
1056       i.state.score++;
1057     }
1058
1059     i.state.my_vote = new_vote;
1060
1061     let form: CreatePostLikeForm = {
1062       post_id: i.props.post.id,
1063       score: i.state.my_vote,
1064     };
1065
1066     WebSocketService.Instance.likePost(form);
1067     i.setState(i.state);
1068     setupTippy();
1069   }
1070
1071   handlePostDisLike(i: PostListing) {
1072     if (!UserService.Instance.user) {
1073       this.context.router.history.push(`/login`);
1074     }
1075
1076     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1077
1078     if (i.state.my_vote == 1) {
1079       i.state.score -= 2;
1080       i.state.upvotes--;
1081       i.state.downvotes++;
1082     } else if (i.state.my_vote == -1) {
1083       i.state.downvotes--;
1084       i.state.score++;
1085     } else {
1086       i.state.downvotes++;
1087       i.state.score--;
1088     }
1089
1090     i.state.my_vote = new_vote;
1091
1092     let form: CreatePostLikeForm = {
1093       post_id: i.props.post.id,
1094       score: i.state.my_vote,
1095     };
1096
1097     WebSocketService.Instance.likePost(form);
1098     i.setState(i.state);
1099     setupTippy();
1100   }
1101
1102   handleEditClick(i: PostListing) {
1103     i.state.showEdit = true;
1104     i.setState(i.state);
1105   }
1106
1107   handleEditCancel() {
1108     this.state.showEdit = false;
1109     this.setState(this.state);
1110   }
1111
1112   // The actual editing is done in the recieve for post
1113   handleEditPost() {
1114     this.state.showEdit = false;
1115     this.setState(this.state);
1116   }
1117
1118   handleDeleteClick(i: PostListing) {
1119     let deleteForm: DeletePostForm = {
1120       edit_id: i.props.post.id,
1121       deleted: !i.props.post.deleted,
1122       auth: null,
1123     };
1124     WebSocketService.Instance.deletePost(deleteForm);
1125   }
1126
1127   handleSavePostClick(i: PostListing) {
1128     let saved = i.props.post.saved == undefined ? true : !i.props.post.saved;
1129     let form: SavePostForm = {
1130       post_id: i.props.post.id,
1131       save: saved,
1132     };
1133
1134     WebSocketService.Instance.savePost(form);
1135   }
1136
1137   get crossPostParams(): string {
1138     let params = `?title=${this.props.post.name}`;
1139     let post = this.props.post;
1140
1141     if (post.url) {
1142       params += `&url=${post.url}`;
1143     }
1144     if (this.props.post.body) {
1145       params += `&body=${this.props.post.body}`;
1146     }
1147     return params;
1148   }
1149
1150   handleModRemoveShow(i: PostListing) {
1151     i.state.showRemoveDialog = true;
1152     i.setState(i.state);
1153   }
1154
1155   handleModRemoveReasonChange(i: PostListing, event: any) {
1156     i.state.removeReason = event.target.value;
1157     i.setState(i.state);
1158   }
1159
1160   handleModRemoveSubmit(i: PostListing) {
1161     event.preventDefault();
1162     let form: RemovePostForm = {
1163       edit_id: i.props.post.id,
1164       removed: !i.props.post.removed,
1165       reason: i.state.removeReason,
1166       auth: null,
1167     };
1168     WebSocketService.Instance.removePost(form);
1169
1170     i.state.showRemoveDialog = false;
1171     i.setState(i.state);
1172   }
1173
1174   handleModLock(i: PostListing) {
1175     let form: LockPostForm = {
1176       edit_id: i.props.post.id,
1177       locked: !i.props.post.locked,
1178       auth: null,
1179     };
1180     WebSocketService.Instance.lockPost(form);
1181   }
1182
1183   handleModSticky(i: PostListing) {
1184     let form: StickyPostForm = {
1185       edit_id: i.props.post.id,
1186       stickied: !i.props.post.stickied,
1187       auth: null,
1188     };
1189     WebSocketService.Instance.stickyPost(form);
1190   }
1191
1192   handleModBanFromCommunityShow(i: PostListing) {
1193     i.state.showBanDialog = true;
1194     i.state.banType = BanType.Community;
1195     i.setState(i.state);
1196   }
1197
1198   handleModBanShow(i: PostListing) {
1199     i.state.showBanDialog = true;
1200     i.state.banType = BanType.Site;
1201     i.setState(i.state);
1202   }
1203
1204   handleModBanReasonChange(i: PostListing, event: any) {
1205     i.state.banReason = event.target.value;
1206     i.setState(i.state);
1207   }
1208
1209   handleModBanExpiresChange(i: PostListing, event: any) {
1210     i.state.banExpires = event.target.value;
1211     i.setState(i.state);
1212   }
1213
1214   handleModBanFromCommunitySubmit(i: PostListing) {
1215     i.state.banType = BanType.Community;
1216     i.setState(i.state);
1217     i.handleModBanBothSubmit(i);
1218   }
1219
1220   handleModBanSubmit(i: PostListing) {
1221     i.state.banType = BanType.Site;
1222     i.setState(i.state);
1223     i.handleModBanBothSubmit(i);
1224   }
1225
1226   handleModBanBothSubmit(i: PostListing) {
1227     event.preventDefault();
1228
1229     if (i.state.banType == BanType.Community) {
1230       let form: BanFromCommunityForm = {
1231         user_id: i.props.post.creator_id,
1232         community_id: i.props.post.community_id,
1233         ban: !i.props.post.banned_from_community,
1234         reason: i.state.banReason,
1235         expires: getUnixTime(i.state.banExpires),
1236       };
1237       WebSocketService.Instance.banFromCommunity(form);
1238     } else {
1239       let form: BanUserForm = {
1240         user_id: i.props.post.creator_id,
1241         ban: !i.props.post.banned,
1242         reason: i.state.banReason,
1243         expires: getUnixTime(i.state.banExpires),
1244       };
1245       WebSocketService.Instance.banUser(form);
1246     }
1247
1248     i.state.showBanDialog = false;
1249     i.setState(i.state);
1250   }
1251
1252   handleAddModToCommunity(i: PostListing) {
1253     let form: AddModToCommunityForm = {
1254       user_id: i.props.post.creator_id,
1255       community_id: i.props.post.community_id,
1256       added: !i.isMod,
1257     };
1258     WebSocketService.Instance.addModToCommunity(form);
1259     i.setState(i.state);
1260   }
1261
1262   handleAddAdmin(i: PostListing) {
1263     let form: AddAdminForm = {
1264       user_id: i.props.post.creator_id,
1265       added: !i.isAdmin,
1266     };
1267     WebSocketService.Instance.addAdmin(form);
1268     i.setState(i.state);
1269   }
1270
1271   handleShowConfirmTransferCommunity(i: PostListing) {
1272     i.state.showConfirmTransferCommunity = true;
1273     i.setState(i.state);
1274   }
1275
1276   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1277     i.state.showConfirmTransferCommunity = false;
1278     i.setState(i.state);
1279   }
1280
1281   handleTransferCommunity(i: PostListing) {
1282     let form: TransferCommunityForm = {
1283       community_id: i.props.post.community_id,
1284       user_id: i.props.post.creator_id,
1285     };
1286     WebSocketService.Instance.transferCommunity(form);
1287     i.state.showConfirmTransferCommunity = false;
1288     i.setState(i.state);
1289   }
1290
1291   handleShowConfirmTransferSite(i: PostListing) {
1292     i.state.showConfirmTransferSite = true;
1293     i.setState(i.state);
1294   }
1295
1296   handleCancelShowConfirmTransferSite(i: PostListing) {
1297     i.state.showConfirmTransferSite = false;
1298     i.setState(i.state);
1299   }
1300
1301   handleTransferSite(i: PostListing) {
1302     let form: TransferSiteForm = {
1303       user_id: i.props.post.creator_id,
1304     };
1305     WebSocketService.Instance.transferSite(form);
1306     i.state.showConfirmTransferSite = false;
1307     i.setState(i.state);
1308   }
1309
1310   handleImageExpandClick(i: PostListing) {
1311     i.state.imageExpanded = !i.state.imageExpanded;
1312     i.setState(i.state);
1313   }
1314
1315   handleViewSource(i: PostListing) {
1316     i.state.viewSource = !i.state.viewSource;
1317     i.setState(i.state);
1318   }
1319
1320   handleShowAdvanced(i: PostListing) {
1321     i.state.showAdvanced = !i.state.showAdvanced;
1322     i.setState(i.state);
1323     setupTippy();
1324   }
1325
1326   get pointsTippy(): string {
1327     let points = i18n.t('number_of_points', {
1328       count: this.state.score,
1329     });
1330
1331     let upvotes = i18n.t('number_of_upvotes', {
1332       count: this.state.upvotes,
1333     });
1334
1335     let downvotes = i18n.t('number_of_downvotes', {
1336       count: this.state.downvotes,
1337     });
1338
1339     return `${points} • ${upvotes} • ${downvotes}`;
1340   }
1341 }