]> Untitled Git - lemmy.git/blob - ui/src/components/post-listing.tsx
A better mobile view, upgrading darkly theme.
[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="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="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             <li className="list-inline-item">•</li>
519             <span
520               class="unselectable pointer mr-2"
521               data-tippy-content={this.pointsTippy}
522             >
523               <li className="list-inline-item">
524                 <a
525                   className={`btn-animate btn btn-link p-0 ${
526                     this.state.my_vote == 1 ? 'text-info' : 'text-muted'
527                   }`}
528                   onClick={linkEvent(this, this.handlePostLike)}
529                 >
530                   <svg class="small icon icon-inline mr-1">
531                     <use xlinkHref="#icon-arrow-up"></use>
532                   </svg>
533                   {this.state.upvotes}
534                 </a>
535               </li>
536               <li className="list-inline-item">
537                 <a
538                   className={`btn-animate btn btn-link p-0 ${
539                     this.state.my_vote == -1 ? 'text-danger' : 'text-muted'
540                   }`}
541                   onClick={linkEvent(this, this.handlePostDisLike)}
542                 >
543                   <svg class="small icon icon-inline mr-1">
544                     <use xlinkHref="#icon-arrow-down"></use>
545                   </svg>
546                   {this.state.downvotes}
547                 </a>
548               </li>
549             </span>
550           </>
551         )}
552       </ul>
553     );
554   }
555
556   duplicatesLine() {
557     return (
558       this.props.post.duplicates && (
559         <ul class="list-inline mb-1 small text-muted">
560           <>
561             <li className="list-inline-item mr-2">
562               {i18n.t('cross_posted_to')}
563             </li>
564             {this.props.post.duplicates.map(post => (
565               <li className="list-inline-item mr-2">
566                 <Link to={`/post/${post.id}`}>{post.community_name}</Link>
567               </li>
568             ))}
569           </>
570         </ul>
571       )
572     );
573   }
574
575   postActions() {
576     let post = this.props.post;
577     return (
578       <ul class="list-inline mb-1 text-muted font-weight-bold">
579         {UserService.Instance.user && (
580           <>
581             {this.props.showBody && (
582               <>
583                 <li className="list-inline-item">
584                   <button
585                     class="btn btn-link btn-animate text-muted"
586                     onClick={linkEvent(this, this.handleSavePostClick)}
587                     data-tippy-content={
588                       post.saved ? i18n.t('unsave') : i18n.t('save')
589                     }
590                   >
591                     <svg
592                       class={`icon icon-inline ${post.saved && 'text-warning'}`}
593                     >
594                       <use xlinkHref="#icon-star"></use>
595                     </svg>
596                   </button>
597                 </li>
598                 <li className="list-inline-item">
599                   <Link
600                     class="btn btn-link btn-animate text-muted"
601                     to={`/create_post${this.crossPostParams}`}
602                     title={i18n.t('cross_post')}
603                   >
604                     <svg class="icon icon-inline">
605                       <use xlinkHref="#icon-copy"></use>
606                     </svg>
607                   </Link>
608                 </li>
609               </>
610             )}
611             {this.myPost && this.props.showBody && (
612               <>
613                 <li className="list-inline-item">
614                   <button
615                     class="btn btn-link btn-animate text-muted"
616                     onClick={linkEvent(this, this.handleEditClick)}
617                     data-tippy-content={i18n.t('edit')}
618                   >
619                     <svg class="icon icon-inline">
620                       <use xlinkHref="#icon-edit"></use>
621                     </svg>
622                   </button>
623                 </li>
624                 <li className="list-inline-item">
625                   <button
626                     class="btn btn-link btn-animate text-muted"
627                     onClick={linkEvent(this, this.handleDeleteClick)}
628                     data-tippy-content={
629                       !post.deleted ? i18n.t('delete') : i18n.t('restore')
630                     }
631                   >
632                     <svg
633                       class={`icon icon-inline ${
634                         post.deleted && 'text-danger'
635                       }`}
636                     >
637                       <use xlinkHref="#icon-trash"></use>
638                     </svg>
639                   </button>
640                 </li>
641               </>
642             )}
643
644             {!this.state.showAdvanced && this.props.showBody ? (
645               <li className="list-inline-item">
646                 <button
647                   class="btn btn-link btn-animate text-muted"
648                   onClick={linkEvent(this, this.handleShowAdvanced)}
649                   data-tippy-content={i18n.t('more')}
650                 >
651                   <svg class="icon icon-inline">
652                     <use xlinkHref="#icon-more-vertical"></use>
653                   </svg>
654                 </button>
655               </li>
656             ) : (
657               <>
658                 {this.props.showBody && post.body && (
659                   <li className="list-inline-item">
660                     <button
661                       class="btn btn-link btn-animate text-muted"
662                       onClick={linkEvent(this, this.handleViewSource)}
663                       data-tippy-content={i18n.t('view_source')}
664                     >
665                       <svg
666                         class={`icon icon-inline ${
667                           this.state.viewSource && 'text-success'
668                         }`}
669                       >
670                         <use xlinkHref="#icon-file-text"></use>
671                       </svg>
672                     </button>
673                   </li>
674                 )}
675                 {this.canModOnSelf && (
676                   <>
677                     <li className="list-inline-item">
678                       <button
679                         class="btn btn-link btn-animate text-muted"
680                         onClick={linkEvent(this, this.handleModLock)}
681                         data-tippy-content={
682                           post.locked ? i18n.t('unlock') : i18n.t('lock')
683                         }
684                       >
685                         <svg
686                           class={`icon icon-inline ${
687                             post.locked && 'text-danger'
688                           }`}
689                         >
690                           <use xlinkHref="#icon-lock"></use>
691                         </svg>
692                       </button>
693                     </li>
694                     <li className="list-inline-item">
695                       <button
696                         class="btn btn-link btn-animate text-muted"
697                         onClick={linkEvent(this, this.handleModSticky)}
698                         data-tippy-content={
699                           post.stickied ? i18n.t('unsticky') : i18n.t('sticky')
700                         }
701                       >
702                         <svg
703                           class={`icon icon-inline ${
704                             post.stickied && 'text-success'
705                           }`}
706                         >
707                           <use xlinkHref="#icon-pin"></use>
708                         </svg>
709                       </button>
710                     </li>
711                   </>
712                 )}
713                 {/* Mods can ban from community, and appoint as mods to community */}
714                 {(this.canMod || this.canAdmin) && (
715                   <li className="list-inline-item">
716                     {!post.removed ? (
717                       <span
718                         class="pointer"
719                         onClick={linkEvent(this, this.handleModRemoveShow)}
720                       >
721                         {i18n.t('remove')}
722                       </span>
723                     ) : (
724                       <span
725                         class="pointer"
726                         onClick={linkEvent(this, this.handleModRemoveSubmit)}
727                       >
728                         {i18n.t('restore')}
729                       </span>
730                     )}
731                   </li>
732                 )}
733                 {this.canMod && (
734                   <>
735                     {!this.isMod && (
736                       <li className="list-inline-item">
737                         {!post.banned_from_community ? (
738                           <span
739                             class="pointer"
740                             onClick={linkEvent(
741                               this,
742                               this.handleModBanFromCommunityShow
743                             )}
744                           >
745                             {i18n.t('ban')}
746                           </span>
747                         ) : (
748                           <span
749                             class="pointer"
750                             onClick={linkEvent(
751                               this,
752                               this.handleModBanFromCommunitySubmit
753                             )}
754                           >
755                             {i18n.t('unban')}
756                           </span>
757                         )}
758                       </li>
759                     )}
760                     {!post.banned_from_community && (
761                       <li className="list-inline-item">
762                         <span
763                           class="pointer"
764                           onClick={linkEvent(
765                             this,
766                             this.handleAddModToCommunity
767                           )}
768                         >
769                           {this.isMod
770                             ? i18n.t('remove_as_mod')
771                             : i18n.t('appoint_as_mod')}
772                         </span>
773                       </li>
774                     )}
775                   </>
776                 )}
777                 {/* Community creators and admins can transfer community to another mod */}
778                 {(this.amCommunityCreator || this.canAdmin) && this.isMod && (
779                   <li className="list-inline-item">
780                     {!this.state.showConfirmTransferCommunity ? (
781                       <span
782                         class="pointer"
783                         onClick={linkEvent(
784                           this,
785                           this.handleShowConfirmTransferCommunity
786                         )}
787                       >
788                         {i18n.t('transfer_community')}
789                       </span>
790                     ) : (
791                       <>
792                         <span class="d-inline-block mr-1">
793                           {i18n.t('are_you_sure')}
794                         </span>
795                         <span
796                           class="pointer d-inline-block mr-1"
797                           onClick={linkEvent(
798                             this,
799                             this.handleTransferCommunity
800                           )}
801                         >
802                           {i18n.t('yes')}
803                         </span>
804                         <span
805                           class="pointer d-inline-block"
806                           onClick={linkEvent(
807                             this,
808                             this.handleCancelShowConfirmTransferCommunity
809                           )}
810                         >
811                           {i18n.t('no')}
812                         </span>
813                       </>
814                     )}
815                   </li>
816                 )}
817                 {/* Admins can ban from all, and appoint other admins */}
818                 {this.canAdmin && (
819                   <>
820                     {!this.isAdmin && (
821                       <li className="list-inline-item">
822                         {!post.banned ? (
823                           <span
824                             class="pointer"
825                             onClick={linkEvent(this, this.handleModBanShow)}
826                           >
827                             {i18n.t('ban_from_site')}
828                           </span>
829                         ) : (
830                           <span
831                             class="pointer"
832                             onClick={linkEvent(this, this.handleModBanSubmit)}
833                           >
834                             {i18n.t('unban_from_site')}
835                           </span>
836                         )}
837                       </li>
838                     )}
839                     {!post.banned && (
840                       <li className="list-inline-item">
841                         <span
842                           class="pointer"
843                           onClick={linkEvent(this, this.handleAddAdmin)}
844                         >
845                           {this.isAdmin
846                             ? i18n.t('remove_as_admin')
847                             : i18n.t('appoint_as_admin')}
848                         </span>
849                       </li>
850                     )}
851                   </>
852                 )}
853                 {/* Site Creator can transfer to another admin */}
854                 {this.amSiteCreator && this.isAdmin && (
855                   <li className="list-inline-item">
856                     {!this.state.showConfirmTransferSite ? (
857                       <span
858                         class="pointer"
859                         onClick={linkEvent(
860                           this,
861                           this.handleShowConfirmTransferSite
862                         )}
863                       >
864                         {i18n.t('transfer_site')}
865                       </span>
866                     ) : (
867                       <>
868                         <span class="d-inline-block mr-1">
869                           {i18n.t('are_you_sure')}
870                         </span>
871                         <span
872                           class="pointer d-inline-block mr-1"
873                           onClick={linkEvent(this, this.handleTransferSite)}
874                         >
875                           {i18n.t('yes')}
876                         </span>
877                         <span
878                           class="pointer d-inline-block"
879                           onClick={linkEvent(
880                             this,
881                             this.handleCancelShowConfirmTransferSite
882                           )}
883                         >
884                           {i18n.t('no')}
885                         </span>
886                       </>
887                     )}
888                   </li>
889                 )}
890               </>
891             )}
892           </>
893         )}
894       </ul>
895     );
896   }
897
898   removeAndBanDialogs() {
899     let post = this.props.post;
900     return (
901       <>
902         {this.state.showRemoveDialog && (
903           <form
904             class="form-inline"
905             onSubmit={linkEvent(this, this.handleModRemoveSubmit)}
906           >
907             <input
908               type="text"
909               class="form-control mr-2"
910               placeholder={i18n.t('reason')}
911               value={this.state.removeReason}
912               onInput={linkEvent(this, this.handleModRemoveReasonChange)}
913             />
914             <button type="submit" class="btn btn-secondary">
915               {i18n.t('remove_post')}
916             </button>
917           </form>
918         )}
919         {this.state.showBanDialog && (
920           <form onSubmit={linkEvent(this, this.handleModBanBothSubmit)}>
921             <div class="form-group row">
922               <label class="col-form-label" htmlFor="post-listing-reason">
923                 {i18n.t('reason')}
924               </label>
925               <input
926                 type="text"
927                 id="post-listing-reason"
928                 class="form-control mr-2"
929                 placeholder={i18n.t('reason')}
930                 value={this.state.banReason}
931                 onInput={linkEvent(this, this.handleModBanReasonChange)}
932               />
933             </div>
934             {/* TODO hold off on expires until later */}
935             {/* <div class="form-group row"> */}
936             {/*   <label class="col-form-label">Expires</label> */}
937             {/*   <input type="date" class="form-control mr-2" placeholder={i18n.t('expires')} value={this.state.banExpires} onInput={linkEvent(this, this.handleModBanExpiresChange)} /> */}
938             {/* </div> */}
939             <div class="form-group row">
940               <button type="submit" class="btn btn-secondary">
941                 {i18n.t('ban')} {post.creator_name}
942               </button>
943             </div>
944           </form>
945         )}
946       </>
947     );
948   }
949
950   mobileThumbnail() {
951     return this.props.post.thumbnail_url || isImage(this.props.post.url) ? (
952       <div class="row">
953         <div class="col-8">{this.postTitleLine()}</div>
954         <div class="col-4">
955           {/* Post body prev or thumbnail */}
956           {!this.state.imageExpanded && this.thumbnail()}
957         </div>
958       </div>
959     ) : (
960       this.postTitleLine()
961     );
962   }
963
964   showMobilePreview() {
965     return (
966       this.props.post.body &&
967       !this.props.showBody && (
968         <div
969           className="md-div mb-1"
970           dangerouslySetInnerHTML={{
971             __html: md.render(previewLines(this.props.post.body)),
972           }}
973         />
974       )
975     );
976   }
977
978   listing() {
979     return (
980       <>
981         {/* The mobile view*/}
982         <div class="d-block d-sm-none">
983           <div class="row">
984             <div class="col-12">
985               {this.createdLine()}
986
987               {/* If it has a thumbnail, do a right aligned thumbnail */}
988               {this.mobileThumbnail()}
989
990               {/* Show a preview of the post body */}
991               {this.showMobilePreview()}
992
993               {this.commentsLine(true)}
994               {this.duplicatesLine()}
995               {this.postActions()}
996               {this.removeAndBanDialogs()}
997             </div>
998           </div>
999         </div>
1000
1001         {/* The larger view*/}
1002         <div class="d-none d-sm-block">
1003           <div class="row">
1004             {this.voteBar()}
1005             {!this.state.imageExpanded && (
1006               <div class="col-sm-2 pr-0">
1007                 <div class="">{this.thumbnail()}</div>
1008               </div>
1009             )}
1010             <div
1011               class={`${
1012                 this.state.imageExpanded ? 'col-12' : 'col-12 col-sm-9'
1013               }`}
1014             >
1015               <div class="row">
1016                 <div className="col-12">
1017                   {this.postTitleLine()}
1018                   {this.createdLine()}
1019                   {this.commentsLine()}
1020                   {this.duplicatesLine()}
1021                   {this.postActions()}
1022                   {this.removeAndBanDialogs()}
1023                 </div>
1024               </div>
1025             </div>
1026           </div>
1027         </div>
1028       </>
1029     );
1030   }
1031
1032   private get myPost(): boolean {
1033     return (
1034       UserService.Instance.user &&
1035       this.props.post.creator_id == UserService.Instance.user.id
1036     );
1037   }
1038
1039   get isMod(): boolean {
1040     return (
1041       this.props.moderators &&
1042       isMod(
1043         this.props.moderators.map(m => m.user_id),
1044         this.props.post.creator_id
1045       )
1046     );
1047   }
1048
1049   get isAdmin(): boolean {
1050     return (
1051       this.props.admins &&
1052       isMod(
1053         this.props.admins.map(a => a.id),
1054         this.props.post.creator_id
1055       )
1056     );
1057   }
1058
1059   get canMod(): boolean {
1060     if (this.props.admins && this.props.moderators) {
1061       let adminsThenMods = this.props.admins
1062         .map(a => a.id)
1063         .concat(this.props.moderators.map(m => m.user_id));
1064
1065       return canMod(
1066         UserService.Instance.user,
1067         adminsThenMods,
1068         this.props.post.creator_id
1069       );
1070     } else {
1071       return false;
1072     }
1073   }
1074
1075   get canModOnSelf(): boolean {
1076     if (this.props.admins && this.props.moderators) {
1077       let adminsThenMods = this.props.admins
1078         .map(a => a.id)
1079         .concat(this.props.moderators.map(m => m.user_id));
1080
1081       return canMod(
1082         UserService.Instance.user,
1083         adminsThenMods,
1084         this.props.post.creator_id,
1085         true
1086       );
1087     } else {
1088       return false;
1089     }
1090   }
1091
1092   get canAdmin(): boolean {
1093     return (
1094       this.props.admins &&
1095       canMod(
1096         UserService.Instance.user,
1097         this.props.admins.map(a => a.id),
1098         this.props.post.creator_id
1099       )
1100     );
1101   }
1102
1103   get amCommunityCreator(): boolean {
1104     return (
1105       this.props.moderators &&
1106       UserService.Instance.user &&
1107       this.props.post.creator_id != UserService.Instance.user.id &&
1108       UserService.Instance.user.id == this.props.moderators[0].user_id
1109     );
1110   }
1111
1112   get amSiteCreator(): boolean {
1113     return (
1114       this.props.admins &&
1115       UserService.Instance.user &&
1116       this.props.post.creator_id != UserService.Instance.user.id &&
1117       UserService.Instance.user.id == this.props.admins[0].id
1118     );
1119   }
1120
1121   handlePostLike(i: PostListing) {
1122     if (!UserService.Instance.user) {
1123       this.context.router.history.push(`/login`);
1124     }
1125
1126     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1127
1128     if (i.state.my_vote == 1) {
1129       i.state.score--;
1130       i.state.upvotes--;
1131     } else if (i.state.my_vote == -1) {
1132       i.state.downvotes--;
1133       i.state.upvotes++;
1134       i.state.score += 2;
1135     } else {
1136       i.state.upvotes++;
1137       i.state.score++;
1138     }
1139
1140     i.state.my_vote = new_vote;
1141
1142     let form: CreatePostLikeForm = {
1143       post_id: i.props.post.id,
1144       score: i.state.my_vote,
1145     };
1146
1147     WebSocketService.Instance.likePost(form);
1148     i.setState(i.state);
1149     setupTippy();
1150   }
1151
1152   handlePostDisLike(i: PostListing) {
1153     if (!UserService.Instance.user) {
1154       this.context.router.history.push(`/login`);
1155     }
1156
1157     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1158
1159     if (i.state.my_vote == 1) {
1160       i.state.score -= 2;
1161       i.state.upvotes--;
1162       i.state.downvotes++;
1163     } else if (i.state.my_vote == -1) {
1164       i.state.downvotes--;
1165       i.state.score++;
1166     } else {
1167       i.state.downvotes++;
1168       i.state.score--;
1169     }
1170
1171     i.state.my_vote = new_vote;
1172
1173     let form: CreatePostLikeForm = {
1174       post_id: i.props.post.id,
1175       score: i.state.my_vote,
1176     };
1177
1178     WebSocketService.Instance.likePost(form);
1179     i.setState(i.state);
1180     setupTippy();
1181   }
1182
1183   handleEditClick(i: PostListing) {
1184     i.state.showEdit = true;
1185     i.setState(i.state);
1186   }
1187
1188   handleEditCancel() {
1189     this.state.showEdit = false;
1190     this.setState(this.state);
1191   }
1192
1193   // The actual editing is done in the recieve for post
1194   handleEditPost() {
1195     this.state.showEdit = false;
1196     this.setState(this.state);
1197   }
1198
1199   handleDeleteClick(i: PostListing) {
1200     let deleteForm: DeletePostForm = {
1201       edit_id: i.props.post.id,
1202       deleted: !i.props.post.deleted,
1203       auth: null,
1204     };
1205     WebSocketService.Instance.deletePost(deleteForm);
1206   }
1207
1208   handleSavePostClick(i: PostListing) {
1209     let saved = i.props.post.saved == undefined ? true : !i.props.post.saved;
1210     let form: SavePostForm = {
1211       post_id: i.props.post.id,
1212       save: saved,
1213     };
1214
1215     WebSocketService.Instance.savePost(form);
1216   }
1217
1218   get crossPostParams(): string {
1219     let params = `?title=${this.props.post.name}`;
1220     let post = this.props.post;
1221
1222     if (post.url) {
1223       params += `&url=${post.url}`;
1224     }
1225     if (this.props.post.body) {
1226       params += `&body=${this.props.post.body}`;
1227     }
1228     return params;
1229   }
1230
1231   handleModRemoveShow(i: PostListing) {
1232     i.state.showRemoveDialog = true;
1233     i.setState(i.state);
1234   }
1235
1236   handleModRemoveReasonChange(i: PostListing, event: any) {
1237     i.state.removeReason = event.target.value;
1238     i.setState(i.state);
1239   }
1240
1241   handleModRemoveSubmit(i: PostListing) {
1242     event.preventDefault();
1243     let form: RemovePostForm = {
1244       edit_id: i.props.post.id,
1245       removed: !i.props.post.removed,
1246       reason: i.state.removeReason,
1247       auth: null,
1248     };
1249     WebSocketService.Instance.removePost(form);
1250
1251     i.state.showRemoveDialog = false;
1252     i.setState(i.state);
1253   }
1254
1255   handleModLock(i: PostListing) {
1256     let form: LockPostForm = {
1257       edit_id: i.props.post.id,
1258       locked: !i.props.post.locked,
1259       auth: null,
1260     };
1261     WebSocketService.Instance.lockPost(form);
1262   }
1263
1264   handleModSticky(i: PostListing) {
1265     let form: StickyPostForm = {
1266       edit_id: i.props.post.id,
1267       stickied: !i.props.post.stickied,
1268       auth: null,
1269     };
1270     WebSocketService.Instance.stickyPost(form);
1271   }
1272
1273   handleModBanFromCommunityShow(i: PostListing) {
1274     i.state.showBanDialog = true;
1275     i.state.banType = BanType.Community;
1276     i.setState(i.state);
1277   }
1278
1279   handleModBanShow(i: PostListing) {
1280     i.state.showBanDialog = true;
1281     i.state.banType = BanType.Site;
1282     i.setState(i.state);
1283   }
1284
1285   handleModBanReasonChange(i: PostListing, event: any) {
1286     i.state.banReason = event.target.value;
1287     i.setState(i.state);
1288   }
1289
1290   handleModBanExpiresChange(i: PostListing, event: any) {
1291     i.state.banExpires = event.target.value;
1292     i.setState(i.state);
1293   }
1294
1295   handleModBanFromCommunitySubmit(i: PostListing) {
1296     i.state.banType = BanType.Community;
1297     i.setState(i.state);
1298     i.handleModBanBothSubmit(i);
1299   }
1300
1301   handleModBanSubmit(i: PostListing) {
1302     i.state.banType = BanType.Site;
1303     i.setState(i.state);
1304     i.handleModBanBothSubmit(i);
1305   }
1306
1307   handleModBanBothSubmit(i: PostListing) {
1308     event.preventDefault();
1309
1310     if (i.state.banType == BanType.Community) {
1311       let form: BanFromCommunityForm = {
1312         user_id: i.props.post.creator_id,
1313         community_id: i.props.post.community_id,
1314         ban: !i.props.post.banned_from_community,
1315         reason: i.state.banReason,
1316         expires: getUnixTime(i.state.banExpires),
1317       };
1318       WebSocketService.Instance.banFromCommunity(form);
1319     } else {
1320       let form: BanUserForm = {
1321         user_id: i.props.post.creator_id,
1322         ban: !i.props.post.banned,
1323         reason: i.state.banReason,
1324         expires: getUnixTime(i.state.banExpires),
1325       };
1326       WebSocketService.Instance.banUser(form);
1327     }
1328
1329     i.state.showBanDialog = false;
1330     i.setState(i.state);
1331   }
1332
1333   handleAddModToCommunity(i: PostListing) {
1334     let form: AddModToCommunityForm = {
1335       user_id: i.props.post.creator_id,
1336       community_id: i.props.post.community_id,
1337       added: !i.isMod,
1338     };
1339     WebSocketService.Instance.addModToCommunity(form);
1340     i.setState(i.state);
1341   }
1342
1343   handleAddAdmin(i: PostListing) {
1344     let form: AddAdminForm = {
1345       user_id: i.props.post.creator_id,
1346       added: !i.isAdmin,
1347     };
1348     WebSocketService.Instance.addAdmin(form);
1349     i.setState(i.state);
1350   }
1351
1352   handleShowConfirmTransferCommunity(i: PostListing) {
1353     i.state.showConfirmTransferCommunity = true;
1354     i.setState(i.state);
1355   }
1356
1357   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1358     i.state.showConfirmTransferCommunity = false;
1359     i.setState(i.state);
1360   }
1361
1362   handleTransferCommunity(i: PostListing) {
1363     let form: TransferCommunityForm = {
1364       community_id: i.props.post.community_id,
1365       user_id: i.props.post.creator_id,
1366     };
1367     WebSocketService.Instance.transferCommunity(form);
1368     i.state.showConfirmTransferCommunity = false;
1369     i.setState(i.state);
1370   }
1371
1372   handleShowConfirmTransferSite(i: PostListing) {
1373     i.state.showConfirmTransferSite = true;
1374     i.setState(i.state);
1375   }
1376
1377   handleCancelShowConfirmTransferSite(i: PostListing) {
1378     i.state.showConfirmTransferSite = false;
1379     i.setState(i.state);
1380   }
1381
1382   handleTransferSite(i: PostListing) {
1383     let form: TransferSiteForm = {
1384       user_id: i.props.post.creator_id,
1385     };
1386     WebSocketService.Instance.transferSite(form);
1387     i.state.showConfirmTransferSite = false;
1388     i.setState(i.state);
1389   }
1390
1391   handleImageExpandClick(i: PostListing) {
1392     i.state.imageExpanded = !i.state.imageExpanded;
1393     i.setState(i.state);
1394   }
1395
1396   handleViewSource(i: PostListing) {
1397     i.state.viewSource = !i.state.viewSource;
1398     i.setState(i.state);
1399   }
1400
1401   handleShowAdvanced(i: PostListing) {
1402     i.state.showAdvanced = !i.state.showAdvanced;
1403     i.setState(i.state);
1404     setupTippy();
1405   }
1406
1407   get pointsTippy(): string {
1408     let points = i18n.t('number_of_points', {
1409       count: this.state.score,
1410     });
1411
1412     let upvotes = i18n.t('number_of_upvotes', {
1413       count: this.state.upvotes,
1414     });
1415
1416     let downvotes = i18n.t('number_of_downvotes', {
1417       count: this.state.downvotes,
1418     });
1419
1420     return `${points} • ${upvotes} • ${downvotes}`;
1421   }
1422 }