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