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