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