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