]> Untitled Git - lemmy.git/blob - ui/src/components/post-listing.tsx
Merge branch 'main' into federation-authorisation
[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             <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 className={`${this.state.imageExpanded ? 'col-12' : 'col-8'}`}>
954           {this.postTitleLine()}
955         </div>
956         <div class="col-4">
957           {/* Post body prev or thumbnail */}
958           {!this.state.imageExpanded && this.thumbnail()}
959         </div>
960       </div>
961     ) : (
962       this.postTitleLine()
963     );
964   }
965
966   showMobilePreview() {
967     return (
968       this.props.post.body &&
969       !this.props.showBody && (
970         <div
971           className="md-div mb-1"
972           dangerouslySetInnerHTML={{
973             __html: md.render(previewLines(this.props.post.body)),
974           }}
975         />
976       )
977     );
978   }
979
980   listing() {
981     return (
982       <>
983         {/* The mobile view*/}
984         <div class="d-block d-sm-none">
985           <div class="row">
986             <div class="col-12">
987               {this.createdLine()}
988
989               {/* If it has a thumbnail, do a right aligned thumbnail */}
990               {this.mobileThumbnail()}
991
992               {/* Show a preview of the post body */}
993               {this.showMobilePreview()}
994
995               {this.commentsLine(true)}
996               {this.duplicatesLine()}
997               {this.postActions()}
998               {this.removeAndBanDialogs()}
999             </div>
1000           </div>
1001         </div>
1002
1003         {/* The larger view*/}
1004         <div class="d-none d-sm-block">
1005           <div class="row">
1006             {this.voteBar()}
1007             {!this.state.imageExpanded && (
1008               <div class="col-sm-2 pr-0">
1009                 <div class="">{this.thumbnail()}</div>
1010               </div>
1011             )}
1012             <div
1013               class={`${
1014                 this.state.imageExpanded ? 'col-12' : 'col-12 col-sm-9'
1015               }`}
1016             >
1017               <div class="row">
1018                 <div className="col-12">
1019                   {this.postTitleLine()}
1020                   {this.createdLine()}
1021                   {this.commentsLine()}
1022                   {this.duplicatesLine()}
1023                   {this.postActions()}
1024                   {this.removeAndBanDialogs()}
1025                 </div>
1026               </div>
1027             </div>
1028           </div>
1029         </div>
1030       </>
1031     );
1032   }
1033
1034   private get myPost(): boolean {
1035     return (
1036       UserService.Instance.user &&
1037       this.props.post.creator_id == UserService.Instance.user.id
1038     );
1039   }
1040
1041   get isMod(): boolean {
1042     return (
1043       this.props.moderators &&
1044       isMod(
1045         this.props.moderators.map(m => m.user_id),
1046         this.props.post.creator_id
1047       )
1048     );
1049   }
1050
1051   get isAdmin(): boolean {
1052     return (
1053       this.props.admins &&
1054       isMod(
1055         this.props.admins.map(a => a.id),
1056         this.props.post.creator_id
1057       )
1058     );
1059   }
1060
1061   get canMod(): boolean {
1062     if (this.props.admins && this.props.moderators) {
1063       let adminsThenMods = this.props.admins
1064         .map(a => a.id)
1065         .concat(this.props.moderators.map(m => m.user_id));
1066
1067       return canMod(
1068         UserService.Instance.user,
1069         adminsThenMods,
1070         this.props.post.creator_id
1071       );
1072     } else {
1073       return false;
1074     }
1075   }
1076
1077   get canModOnSelf(): boolean {
1078     if (this.props.admins && this.props.moderators) {
1079       let adminsThenMods = this.props.admins
1080         .map(a => a.id)
1081         .concat(this.props.moderators.map(m => m.user_id));
1082
1083       return canMod(
1084         UserService.Instance.user,
1085         adminsThenMods,
1086         this.props.post.creator_id,
1087         true
1088       );
1089     } else {
1090       return false;
1091     }
1092   }
1093
1094   get canAdmin(): boolean {
1095     return (
1096       this.props.admins &&
1097       canMod(
1098         UserService.Instance.user,
1099         this.props.admins.map(a => a.id),
1100         this.props.post.creator_id
1101       )
1102     );
1103   }
1104
1105   get amCommunityCreator(): boolean {
1106     return (
1107       this.props.moderators &&
1108       UserService.Instance.user &&
1109       this.props.post.creator_id != UserService.Instance.user.id &&
1110       UserService.Instance.user.id == this.props.moderators[0].user_id
1111     );
1112   }
1113
1114   get amSiteCreator(): boolean {
1115     return (
1116       this.props.admins &&
1117       UserService.Instance.user &&
1118       this.props.post.creator_id != UserService.Instance.user.id &&
1119       UserService.Instance.user.id == this.props.admins[0].id
1120     );
1121   }
1122
1123   handlePostLike(i: PostListing) {
1124     if (!UserService.Instance.user) {
1125       this.context.router.history.push(`/login`);
1126     }
1127
1128     let new_vote = i.state.my_vote == 1 ? 0 : 1;
1129
1130     if (i.state.my_vote == 1) {
1131       i.state.score--;
1132       i.state.upvotes--;
1133     } else if (i.state.my_vote == -1) {
1134       i.state.downvotes--;
1135       i.state.upvotes++;
1136       i.state.score += 2;
1137     } else {
1138       i.state.upvotes++;
1139       i.state.score++;
1140     }
1141
1142     i.state.my_vote = new_vote;
1143
1144     let form: CreatePostLikeForm = {
1145       post_id: i.props.post.id,
1146       score: i.state.my_vote,
1147     };
1148
1149     WebSocketService.Instance.likePost(form);
1150     i.setState(i.state);
1151     setupTippy();
1152   }
1153
1154   handlePostDisLike(i: PostListing) {
1155     if (!UserService.Instance.user) {
1156       this.context.router.history.push(`/login`);
1157     }
1158
1159     let new_vote = i.state.my_vote == -1 ? 0 : -1;
1160
1161     if (i.state.my_vote == 1) {
1162       i.state.score -= 2;
1163       i.state.upvotes--;
1164       i.state.downvotes++;
1165     } else if (i.state.my_vote == -1) {
1166       i.state.downvotes--;
1167       i.state.score++;
1168     } else {
1169       i.state.downvotes++;
1170       i.state.score--;
1171     }
1172
1173     i.state.my_vote = new_vote;
1174
1175     let form: CreatePostLikeForm = {
1176       post_id: i.props.post.id,
1177       score: i.state.my_vote,
1178     };
1179
1180     WebSocketService.Instance.likePost(form);
1181     i.setState(i.state);
1182     setupTippy();
1183   }
1184
1185   handleEditClick(i: PostListing) {
1186     i.state.showEdit = true;
1187     i.setState(i.state);
1188   }
1189
1190   handleEditCancel() {
1191     this.state.showEdit = false;
1192     this.setState(this.state);
1193   }
1194
1195   // The actual editing is done in the recieve for post
1196   handleEditPost() {
1197     this.state.showEdit = false;
1198     this.setState(this.state);
1199   }
1200
1201   handleDeleteClick(i: PostListing) {
1202     let deleteForm: DeletePostForm = {
1203       edit_id: i.props.post.id,
1204       deleted: !i.props.post.deleted,
1205       auth: null,
1206     };
1207     WebSocketService.Instance.deletePost(deleteForm);
1208   }
1209
1210   handleSavePostClick(i: PostListing) {
1211     let saved = i.props.post.saved == undefined ? true : !i.props.post.saved;
1212     let form: SavePostForm = {
1213       post_id: i.props.post.id,
1214       save: saved,
1215     };
1216
1217     WebSocketService.Instance.savePost(form);
1218   }
1219
1220   get crossPostParams(): string {
1221     let params = `?title=${this.props.post.name}`;
1222     let post = this.props.post;
1223
1224     if (post.url) {
1225       params += `&url=${post.url}`;
1226     }
1227     if (this.props.post.body) {
1228       params += `&body=${this.props.post.body}`;
1229     }
1230     return params;
1231   }
1232
1233   handleModRemoveShow(i: PostListing) {
1234     i.state.showRemoveDialog = true;
1235     i.setState(i.state);
1236   }
1237
1238   handleModRemoveReasonChange(i: PostListing, event: any) {
1239     i.state.removeReason = event.target.value;
1240     i.setState(i.state);
1241   }
1242
1243   handleModRemoveSubmit(i: PostListing) {
1244     event.preventDefault();
1245     let form: RemovePostForm = {
1246       edit_id: i.props.post.id,
1247       removed: !i.props.post.removed,
1248       reason: i.state.removeReason,
1249       auth: null,
1250     };
1251     WebSocketService.Instance.removePost(form);
1252
1253     i.state.showRemoveDialog = false;
1254     i.setState(i.state);
1255   }
1256
1257   handleModLock(i: PostListing) {
1258     let form: LockPostForm = {
1259       edit_id: i.props.post.id,
1260       locked: !i.props.post.locked,
1261       auth: null,
1262     };
1263     WebSocketService.Instance.lockPost(form);
1264   }
1265
1266   handleModSticky(i: PostListing) {
1267     let form: StickyPostForm = {
1268       edit_id: i.props.post.id,
1269       stickied: !i.props.post.stickied,
1270       auth: null,
1271     };
1272     WebSocketService.Instance.stickyPost(form);
1273   }
1274
1275   handleModBanFromCommunityShow(i: PostListing) {
1276     i.state.showBanDialog = true;
1277     i.state.banType = BanType.Community;
1278     i.setState(i.state);
1279   }
1280
1281   handleModBanShow(i: PostListing) {
1282     i.state.showBanDialog = true;
1283     i.state.banType = BanType.Site;
1284     i.setState(i.state);
1285   }
1286
1287   handleModBanReasonChange(i: PostListing, event: any) {
1288     i.state.banReason = event.target.value;
1289     i.setState(i.state);
1290   }
1291
1292   handleModBanExpiresChange(i: PostListing, event: any) {
1293     i.state.banExpires = event.target.value;
1294     i.setState(i.state);
1295   }
1296
1297   handleModBanFromCommunitySubmit(i: PostListing) {
1298     i.state.banType = BanType.Community;
1299     i.setState(i.state);
1300     i.handleModBanBothSubmit(i);
1301   }
1302
1303   handleModBanSubmit(i: PostListing) {
1304     i.state.banType = BanType.Site;
1305     i.setState(i.state);
1306     i.handleModBanBothSubmit(i);
1307   }
1308
1309   handleModBanBothSubmit(i: PostListing) {
1310     event.preventDefault();
1311
1312     if (i.state.banType == BanType.Community) {
1313       let form: BanFromCommunityForm = {
1314         user_id: i.props.post.creator_id,
1315         community_id: i.props.post.community_id,
1316         ban: !i.props.post.banned_from_community,
1317         reason: i.state.banReason,
1318         expires: getUnixTime(i.state.banExpires),
1319       };
1320       WebSocketService.Instance.banFromCommunity(form);
1321     } else {
1322       let form: BanUserForm = {
1323         user_id: i.props.post.creator_id,
1324         ban: !i.props.post.banned,
1325         reason: i.state.banReason,
1326         expires: getUnixTime(i.state.banExpires),
1327       };
1328       WebSocketService.Instance.banUser(form);
1329     }
1330
1331     i.state.showBanDialog = false;
1332     i.setState(i.state);
1333   }
1334
1335   handleAddModToCommunity(i: PostListing) {
1336     let form: AddModToCommunityForm = {
1337       user_id: i.props.post.creator_id,
1338       community_id: i.props.post.community_id,
1339       added: !i.isMod,
1340     };
1341     WebSocketService.Instance.addModToCommunity(form);
1342     i.setState(i.state);
1343   }
1344
1345   handleAddAdmin(i: PostListing) {
1346     let form: AddAdminForm = {
1347       user_id: i.props.post.creator_id,
1348       added: !i.isAdmin,
1349     };
1350     WebSocketService.Instance.addAdmin(form);
1351     i.setState(i.state);
1352   }
1353
1354   handleShowConfirmTransferCommunity(i: PostListing) {
1355     i.state.showConfirmTransferCommunity = true;
1356     i.setState(i.state);
1357   }
1358
1359   handleCancelShowConfirmTransferCommunity(i: PostListing) {
1360     i.state.showConfirmTransferCommunity = false;
1361     i.setState(i.state);
1362   }
1363
1364   handleTransferCommunity(i: PostListing) {
1365     let form: TransferCommunityForm = {
1366       community_id: i.props.post.community_id,
1367       user_id: i.props.post.creator_id,
1368     };
1369     WebSocketService.Instance.transferCommunity(form);
1370     i.state.showConfirmTransferCommunity = false;
1371     i.setState(i.state);
1372   }
1373
1374   handleShowConfirmTransferSite(i: PostListing) {
1375     i.state.showConfirmTransferSite = true;
1376     i.setState(i.state);
1377   }
1378
1379   handleCancelShowConfirmTransferSite(i: PostListing) {
1380     i.state.showConfirmTransferSite = false;
1381     i.setState(i.state);
1382   }
1383
1384   handleTransferSite(i: PostListing) {
1385     let form: TransferSiteForm = {
1386       user_id: i.props.post.creator_id,
1387     };
1388     WebSocketService.Instance.transferSite(form);
1389     i.state.showConfirmTransferSite = false;
1390     i.setState(i.state);
1391   }
1392
1393   handleImageExpandClick(i: PostListing) {
1394     i.state.imageExpanded = !i.state.imageExpanded;
1395     i.setState(i.state);
1396   }
1397
1398   handleViewSource(i: PostListing) {
1399     i.state.viewSource = !i.state.viewSource;
1400     i.setState(i.state);
1401   }
1402
1403   handleShowAdvanced(i: PostListing) {
1404     i.state.showAdvanced = !i.state.showAdvanced;
1405     i.setState(i.state);
1406     setupTippy();
1407   }
1408
1409   get pointsTippy(): string {
1410     let points = i18n.t('number_of_points', {
1411       count: this.state.score,
1412     });
1413
1414     let upvotes = i18n.t('number_of_upvotes', {
1415       count: this.state.upvotes,
1416     });
1417
1418     let downvotes = i18n.t('number_of_downvotes', {
1419       count: this.state.downvotes,
1420     });
1421
1422     return `${points} • ${upvotes} • ${downvotes}`;
1423   }
1424 }