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