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